Blog / March 18, 2026

Slots Deep Dive: The Architecture That Stops Your Agent From Guessing

Kevin Mok, Staff Developer Advocate

hero-momentum-transparent-circles-horizontal

Table of contents


I built an expense filing plugin a while back. You might have read about it. The File Slots blog covered how we got receipt uploads working with multipart APIs. One slot, one file mapping, done.

But that was one slot out of four. The receipt was the flashy part. The real architecture story is in the other three: the amount, the category, and the notes. Specifically, what happens when a user says "travel expenses" instead of picking category ID CAT-4821 from a dropdown. What happens when the assistant needs to resolve "my department's expense types" into a filtered list that depends on a value the user already provided. What keeps the whole thing from quietly hallucinating an ID that looks right but doesn't exist in your system.

That's what this post is about. The architecture underneath slots. The parts that don't make for good demos but determine whether your plugin works reliably at scale or falls apart the first time someone says something ambiguous.

The Problem With Letting LLMs Guess

Here's what happens when you skip this architecture and let an LLM handle structured data collection on its own.

User says: "Close my bug ticket from yesterday."

The LLM has seen thousands of Jira ticket IDs in its training data. It knows the format. So it generates one. BUG-732. Looks plausible. Might even be a real ticket. But it's not the user's ticket. It's a guess dressed up as a fact.

This is the hallucinated ID problem, and it's not hypothetical. LLMs are pattern completers. They'll produce output that looks structurally correct because they've learned the pattern. A ticket ID that matches the format. A category name that sounds right. An employee ID that has the right number of digits. The output passes a vibe check but fails the API call.

And it gets worse in multi-slot plugins. Your expense plugin needs a category, a department, and an approver. The user provides partial info. The LLM fills in the gaps from context, from training data, from whatever tokens have the highest probability. Three slots, three chances to fabricate something. By the time the action fires, you're sending a request with a real amount, a hallucinated category, and a department that doesn't exist in your org's hierarchy.

The gap between what users say and what APIs need is the entire problem space. Users speak in natural language. APIs speak in IDs, enums, and foreign keys. Something has to bridge that gap without guessing.

Correctness is only half of it. The user experience of getting there matters just as much. A plugin that asks "What's the category ID?" is technically correct but useless. A plugin that asks "Which category?" and shows you three options from your company's actual expense policy is doing the translation for you. That's the architecture we're unpacking here.

The Slot Lifecycle: 30,000-Foot View

Before diving into each component, here's how the full pipeline works. A user utterance flows through several stages before it becomes a validated value your action can use.

 

Lifecycle of a Slot Inference Policy Ask user? Infer? Fallback? ASK USER Resolver Strategy "my bug ticket" JIRA-4821 Working Memory JIRA-4821 LOCKED JIRA-4822 Validation JIRA-4821 VALID ✓ Action Execution createTicket(4821) EXECUTED Not a guess. A system-of-record value. Selected by user. Validated by rules. Executed.
Step 0 of 6

The slot lifecycle: user utterance flows through inference policy, resolver, working memory, and validation before reaching your action.

  1. Inference policy decides whether to ask the user, infer from context, or use a fallback value. This is the first gate. (Covered in Slots 101.)

  2. Resolver strategy converts natural language into structured data. "My bug ticket" becomes a lookup against your Jira API. "Travel" becomes a filtered query against your expense category service. The resolver returns candidates.

  3. Symbolic working memory holds both the candidate list and the selected value. The assistant presents options if there's ambiguity. The user picks. The selected value is locked in memory.

  4. Validation checks the final value against your DSL rules. Date in the past? Rejected. Amount negative? Rejected. Bad data never reaches your action.

  5. Action execution receives the validated, resolver-backed value. Not a guess. Not an extraction from conversation text. A value that came from your system of record, selected by the user, validated by your rules.

Each section below unpacks one layer of this pipeline. The focus is on the architecture decisions that make slots reliable, not on how to configure them in the UI. For configuration, the docs cover that.

 

Got questions about slots? Bring them to Office Hours — we'll unpack it live.

 

Symbolic Working Memory

This is what makes the whole thing reliable. And it's the thing most people skip over when they first look at resolvers.

When a resolver runs, it doesn't just return a value. It returns a list of candidates from your system of record. The assistant evaluates those candidates against what the user said, and either picks the best match or asks the user to disambiguate.

Here's what that means in practice: the slot system uses a symbolic working memory architecture to track both the list of possible values and the selected value. The assistant can't accidentally change IDs or fabricate new ones when providing them to your actions. The value in memory came from your resolver. Period.

Without Resolvers With Resolvers Training Data LLM Guesses CAT-9999 ? Slot CAT-9999 Hallucinated ID ✗ API Resolver Candidates CAT-4821 CAT-4822 CAT-1001 Working Memory CAT-4821 LOCKED Verified ✓
Step 0 of 6

Without resolvers, the LLM fabricates IDs from training data. With resolvers, candidates come from your API and the selected value is locked in memory.

Let me make this concrete with the expense plugin.

Without Symbolic Working Memory

User says: "File a travel expense."

The LLM sees "travel" and generates a category. Maybe it outputs "Travel" as a string. Maybe it outputs "Travel & Entertainment". Maybe it outputs "TRAVEL_EXP" because it saw that format in some training data. Your API expects CAT-4821. None of these work.

Or worse, the LLM does produce something that looks like a valid category ID because it extracted it from earlier in the conversation. But the conversation mentioned two categories, and the LLM picked the wrong one. No error. Just wrong data hitting your expense system.

With Symbolic Working Memory

User says: "File a travel expense."

The resolver calls your company's expense category API. It returns the actual categories:

[
  { "id": "CAT-4821", "name": "Travel", "department": "Engineering" },
  { "id": "CAT-4822", "name": "Travel & Entertainment", "department": "Sales" },
  { "id": "CAT-1001", "name": "Office Supplies", "department": "Engineering" }
]

The assistant fuzzy-matches "travel" against these candidates. Two matches. It asks the user:

"I found two travel categories. Which one?"

1. Travel (Engineering)

2. Travel & Entertainment (Sales)

User picks option 1. The value CAT-4821 is stored in symbolic working memory. From this point forward, the assistant references that stored value. It can't swap it for CAT-4822. It can't invent CAT-9999. The ID in memory is the ID that goes to your action.

The Resolver Method Selection Layer

Before candidates even arrive, the resolver has to decide how to look them up. Dynamic resolvers support multiple methods on the same data type. Each method is a different lookup path.

For the expense category resolver, you might have:

# Method 1: Browse all categories for a department
method_name: get_categories_by_department
action: http_get_expense_categories
input_mapping:
  department_id: data.department.id
output_mapping: response.categories
output_cardinality: list_of_candidates

# Method 2: Search categories by keyword
method_name: search_categories
action: http_search_expense_categories
input_mapping:
  query: data.search_term
output_mapping: response.results
output_cardinality: list_of_candidates

The assistant picks the method based on the conversation. User says "travel"? Probably the search method. User says "show me my department's categories"? The department-based method. The method name matters here. The reasoning engine uses it to decide which path fits. Keep them descriptive, snake_case.

One constraint: a resolver strategy can have one static method or multiple dynamic methods. You can't mix static and dynamic on the same strategy. If you need both a fixed list and a live lookup, they go on separate slots or separate data types.

Why This Matters for Production

The candidate retrieval → disambiguation → selection flow creates a closed loop. Every value that reaches your action passed through your system of record first. The assistant is a matchmaker, not a generator. It connects what the user said to what your system has. It doesn't create new options.

The docs say it straight: "Plugins built using slot resolvers will perform substantially better when deployed to production." Not a throwaway line. A statement about the reliability boundary.

Consider the failure modes:

  • With resolvers: "No match found." The assistant tells the user it couldn't find what they described and asks them to try again. Recoverable. Visible. The user knows something went wrong.
  • Without resolvers: "Wrong value sent to API." The LLM extracted something that looked right. The API accepted it (or returned a cryptic error). The user might not even know the wrong category was filed. Silent corruption.

The first failure mode is annoying. The second is dangerous. Resolvers trade a small amount of configuration work for a fundamentally different error boundary.

Now that you've seen how a single slot gets resolved, the next question is: what happens when slots depend on each other?

Context Passing and Data Bank Isolation

Resolvers don't operate in a vacuum. In most real plugins, one slot's value affects what another slot should offer. Department determines which expense types are valid. Country determines which cities appear. Employee determines which time-off types are available.

This is context passing, and it's how you wire slots together.

How Context Passing Works

Say your expense plugin has two slots: department and expense_type. The expense types available depend on which department the user belongs to. Engineering gets "Travel," "Equipment," "Conference." Sales gets "Travel & Entertainment," "Client Meals," "Events."

Here's the wiring:

  1. The department slot resolves first (more on ordering in a moment).
  2. In the expense_type slot's resolver, you use View Strategy Mapping to map the department slot's resolved value as a context-passed input.
Context Passing Department Slot Engineering data.department Expense Type Resolver Engineering Travel Equipment Conference Travel & Entertainment Client Meals Events Filtered Candidates Travel Equipment Conference 3 of 6 candidates ✓
Step 0 of 5

The department slot's resolved value travels to the expense type resolver, which filters candidates to only those valid for Engineering.

Inside the expense_type resolver, the department value arrives as data.department (or whatever key you mapped it to). Your resolver's HTTP action uses that value to filter the API call:

# expense_type resolver method
method_name: get_expense_types_by_department
action: http_get_expense_types
input_mapping:
  department_id: data.department.id # context-passed from department slot
output_mapping: response.expense_types
output_cardinality: list_of_candidates

The API returns only the expense types valid for that department. The user sees a filtered list. No "Travel & Entertainment" showing up for an engineer. No "Equipment" showing up for a salesperson.

The resolver method doesn't need input arguments defined in its schema for context-passed values. Context passing bypasses the "collect from user" flow entirely. The value comes from another slot, not from the user.

This is different from resolver input arguments, which are collected from the user. Input arguments are like mini-slots inside the resolver. If your resolver method has an input argument called feature_request_id, the reasoning engine will ask the user for that value before running the resolver. Context-passed values skip that collection step entirely.

# Input arguments: collected from the user
input_args:
  feature_request_id:
    type: string # reasoning engine asks user for this

# Context passing: wired from another slot
context_mapping:
  department_id: data.department.id # comes from department slot, no user prompt

The distinction matters because it affects the conversation flow. Input arguments add prompts. Context passing is invisible to the user.

The Data Bank Isolation Rule

Here's where it gets strict. Inside a dynamic resolver, you can reference:

  • Context-passed values via data.<key_name> (values explicitly mapped through the strategy mapper)
  • Input arguments via data.<input_arg> (values collected from the user for this resolver)
  • User attributes via meta_info.user (the current user's profile data)

That's it.

You cannot reference the conversational process data bank from inside a resolver. If your conversation process has an activity that outputs data.previous_action_result, that key does not exist inside your resolver. If you have a slot called notes that the user already filled, you can't read data.notes from inside another slot's resolver unless you explicitly context-pass it.

# What's available inside a resolver's data bank:
data.{context_passed_key}     # ✅ explicitly mapped via Strategy Mapping
data.{input_arg}              # ✅ defined in resolver's input schema
meta_info.user                # ✅ always available
meta_info.user.email_addr     # ✅ user attributes

data.{slot_name}              # ❌ NOT available (conversation process scope)
data.{output_key}             # ❌ NOT available (conversation process scope)

This isolation is strict by design. Only keys that have been explicitly context-passed via the strategy mapper on the slot become part of the resolver's data bank, in addition to input arguments defined on the resolver for collection. It forces you to declare your dependencies explicitly. No implicit coupling between your resolver and whatever else is happening in the conversation process.

The practical consequence: if your resolver needs a value from another slot, you must wire it through the strategy mapper. There's no shortcut. This feels restrictive the first time you hit it, but it means every resolver is self-contained. You can look at a resolver's configuration and know exactly what data it has access to. No hidden dependencies.

Why does this matter beyond cleanliness? Two reasons.

First, it prevents accidental data leakage. If resolvers had full data bank access, a resolver for "expense type" could accidentally reference sensitive data from a previous action's output. Maybe that action returned salary information, or PII from another system. Isolation means the resolver only sees what you explicitly gave it. The blast radius of a misconfiguration is limited to the context-passed values.

Second, it makes resolvers portable. A resolver strategy attached to a data type (like ExpenseCategory) can be reused across multiple plugins. If it had implicit dependencies on specific conversation process data bank keys, it would break the moment you used it in a different plugin with different keys. Isolation forces the resolver to be self-contained, which is what makes reusability work.

The Ordering Gotcha

Context passing has a dependency you need to get right: slot ordering.

The reasoning engine processes required slots from left to right in the Required Slots list. If slot B's resolver depends on slot A's value (via context passing), slot A must appear to the left of slot B.

Required Slots: [ department, expense_type, amount, notes ]
                  ↑                ↑
                  resolves first   uses department's value

# ✅ This works. department resolves before expense_type needs it.
Required Slots: [ expense_type, department, amount, notes ]
                  ↑                ↑
                  needs department  resolves second

# ❌ This breaks. expense_type's resolver fires before department
#    has a value. data.department is null. No error message.
#    Your resolver just gets an empty input and returns unfiltered results.

There's no explicit dependency declaration syntax. No depends_on: department annotation. The dependency is implicit in the left-to-right position. Get the order wrong and you won't see an error. You'll see your resolver returning unfiltered results because the context-passed value was null.

This is one of those things that's obvious once you know it and invisible until you do. If your resolver is returning too many results or ignoring a filter, check the slot order first.

So far we've covered how individual slots resolve and how they pass data to each other. But not every slot needs a conversation turn. Some have sensible defaults. Some should only fire if the user volunteers the information.

Optional Slots and Fallbacks

Not every slot needs a conversation turn. Some have sensible defaults. Some should only be collected if the user volunteers the information. Optional slots and fallback expressions handle this.

The Three Inference Policies

Every slot has an inference policy that controls collection behavior.

  • Always Ask: Prompts every time, even if the value could be inferred. Use for sensitive inputs.
  • Infer if Available: Checks context first, asks only if ambiguous. The default.
  • Always Infer: Never prompts. Infers from context or uses the fallback. Requires a fallback to be defined.

The interesting pattern is Always Infer + fallback expression. This creates a "silent default" slot. The user never sees it. The value is either inferred from something they already said, or it falls back to a predefined default.

Fallback Expression Syntax

Fallback values use a DSL that supports static values, dynamic references, and object construction.

Static fallback:

"'Expense submitted via agent'"

A notes slot with "Always Infer" and this fallback means: if the user mentions notes in their request, capture them. If not, silently default to "Expense submitted via agent." No prompt. No interruption.

Dynamic fallback referencing another slot:

data.recent_address.city

A city slot that falls back to the city from a previously resolved address. If the user doesn't specify a new city, use the one on file.

Optional Slots File a $30 travel expense Amount INFER IF AVAILABLE $30 Category INFER IF AVAILABLE Travel (Engineering) Currency ALWAYS INFER USD inferred from "$" Notes ALWAYS INFER Expense submitted via agent fallback 2 prompted · 2 silent · 1 turn
Step 0 of 5

Four slots, one turn. Inference policies and fallbacks eliminate unnecessary prompts.

The Fallback Ordering Catch

Fallback expressions that reference other slots have the same ordering dependency as context passing. The referenced slot must be collected first.

Required Slots: [ department, currency, amount, notes ]

If currency has a fallback expression data.department.default_currency, then department must be to the left of currency. The reasoning engine processes left to right. If currency comes first, data.department doesn't exist yet, and the fallback resolves to null. No error. Just a null currency value hitting your action.

# currency fallback: data.department.default_currency

Required Slots: [ department, currency, ... ]
#                  ↑            ↑
#                  collected    fallback references department ✅

Required Slots: [ currency, department, ... ]
#                  ↑            ↑
#                  fallback fires here, department is null ❌

Same gotcha as context passing. Same fix: check the left-to-right order.

Practical Patterns

Here's how I wire the expense plugin's optional slots (codified):

slots:
  amount:
    type: Number
    description: 'The expense amount'
    inference_policy: always_ask # Always confirm money amounts

  category:
    type: ExpenseCategory
    description: 'The expense category'
    inference_policy: infer_if_available
    resolver: expense_category_resolver

  currency:
    type: Currency
    description: 'Currency for the expense'
    inference_policy: always_infer
    fallback: |
      {"value": "USD", "display_value": "US Dollar"}

  notes:
    type: String
    description: 'Notes about the expense'
    inference_policy: always_infer
    fallback: "'Expense submitted via agent'"

Four slots. Two prompts (amount always, category if ambiguous). Two silent defaults (currency and notes). The user says "File a $30 travel expense" and the plugin collects everything it needs in one turn. Amount: $30. Category: resolved via the expense category resolver. Currency: USD (fallback). Notes: "Expense submitted via agent" (fallback).

Compare that to a plugin that asks four questions in sequence. Four LLM round trips. Four waits. The difference is inference policy + fallback expressions.

One thing to watch: when a fallback is defined for non-"Always Infer" slots, users can explicitly choose to use the fallback value during the conversation. Previously, only slots with resolvers gave users selectable options. Fallbacks expand that interaction model. The user can say "just use the default" and the fallback kicks in, even on an "Infer if Available" slot.

Everything so far has been about collecting a single value per slot. But some plugins need more than one.

List Slots

Some plugins need more than one value for a slot. A calendar invite needs multiple attendees. A Jira ticket needs multiple watchers. A notification needs multiple recipients.

List slots handle this. Instead of User, you configure the slot as List[User]. Instead of collecting one value, the assistant collects multiple.

How It Works

Let's switch examples here. The expense plugin doesn't naturally need list slots (File Slots are single-file, and the other fields are singular values). A better fit: a Jira plugin that adds watchers to a ticket.

slots:
  ticket:
    type: JiraIssue
    description: 'The Jira ticket to update'
    resolver: jira_issue_resolver

  watchers:
    type: List[User]
    description: 'Users to add as watchers on this ticket'
    resolver: user_resolver

The watchers slot is List[User]. Each value in the list gets validated against the user_resolver. The assistant handles multi-value collection conversationally:

All at once:

User: "Add Alice, Bob, and Carol as watchers on BUG-732." Assistant: resolves each name against the user resolver, disambiguates if needed

One at a time:

User: "Add Alice as a watcher." Assistant: "Added Alice. Anyone else?" User: "Bob too." Assistant: "Added Bob. Anyone else?" User: "That's it."

Each individual value goes through the same resolver pipeline. "Alice" gets resolved against your user directory. If there are three Alices, the assistant disambiguates. The resolved user object goes into the list. Same symbolic working memory guarantees, applied per item.

What to Know

The source material on list slots is thin, so I'll keep this proportional.

  • Check the "Data type is expected to be a list" option when creating the slot. That's the toggle between [type] and List[type].
  • If your list data type uses a resolver strategy, each record is matched against a valid candidate value. No unvalidated entries sneak into the list.
  • The assistant handles the conversational flow of collecting multiple values. You don't need to build a loop or manage state.

The Resolver Guarantee Per Item

The important architectural detail: each item in the list goes through the full resolver pipeline independently. If you add five watchers, that's five resolver lookups, five disambiguation checks, five symbolic memory entries. The list doesn't bypass the resolver. It runs it per item.

This means you can't sneak an invalid user into the list. "Add Alice, FakeUser123, and Bob" would resolve Alice and Bob from your directory and fail on FakeUser123. The assistant would flag the unresolvable entry and ask the user to clarify.

List slots work well for List[User] (attendees, watchers, approvers), List[JiraIssue] (bulk operations), or any scenario where the user needs to select multiple items from the same data type.

Rough Edges

Honest accounting of what's still limited.

Resolver data bank isolation can surprise you. If you're used to having full data bank access everywhere, the first time you try to reference data.some_slot inside a resolver and get null, it's confusing. The isolation is intentional, but the error mode is silent. You just get null values, not an error message telling you the key doesn't exist in this scope.

Slot ordering is implicit. There's no depends_on annotation. No dependency graph visualization. Just left-to-right position in the Required Slots list. If you have complex dependencies between slots, you're managing them by position. Get one out of order and you'll spend time debugging null values before you realize it's a sequencing issue.

Compound actions can't be used in resolvers. Only HTTP actions (and Script/Built-in actions) work as resolver methods. If your lookup logic requires multiple API calls chained together, you need to handle that in a single HTTP action or restructure your approach.

Static and dynamic methods can't coexist on one resolver. A resolver strategy gets one static method or multiple dynamic methods. Not both. If you need a fixed fallback list plus a live lookup, you'll need to restructure. Usually the answer is making the static options part of your API response so a single dynamic method covers both cases.

Wrapping Up

The five architecture pieces covered here, symbolic working memory, context passing with data bank isolation, the resolver-over-action pattern, optional slots with fallbacks, and list slots, are what separate a plugin that demos well from one that works in production.

The common thread: every design decision pushes toward values from your system of record, not values from the LLM's imagination. Resolvers fetch from your APIs. Working memory locks in the selection. Context passing wires dependencies explicitly. Fallbacks use your data bank, not generated defaults. List slots validate each item individually.

None of these are complicated individually. Symbolic working memory is just "values come from your API, not the LLM." Context passing is just "slot A feeds slot B." The anti-pattern fix is just "move the action into a resolver." Fallbacks are just "set a default." List slots are just "collect more than one."

But together, they form an architecture where the LLM handles what it's good at (understanding natural language, managing conversation flow, picking the right resolver method) and your systems of record handle what they're good at (being the source of truth for IDs, categories, users, and business objects). The LLM never has to guess at structured data. That's the whole point.

If you're building plugins with resolvers, come share what you're working on in the community. If you're evaluating whether this architecture fits your use case, the resolver strategies docs are the densest reference, and the slot resolvers overview is the best starting point.

The content of this blog post is for informational purposes only.

Subscribe to our Insights blog