KDCube

An app creates a ReAct agent mostly by declaring it.

There is no agent object to keep alive, no long-lived process to manage. A ReAct agent is assembled fresh for every turn from three inputs: the app's code (the workflow that calls build_react), the app's config (the per-agent blocks in bundles.yaml), and — since the per-user selection layer — the user's own saved choices. Change the config, and the next turn runs a different agent. Let the user toggle a tool off, and the next turn is built without it.

app code                 app config                the user's choices
(workflow calls          (per-agent blocks         (saved deny-list
 build_react)             in bundles.yaml)          + model pick)
      \                        |                        /
       \                       |                       /
        v                      v                      v
        ┌─────────────────────────────────────────────┐
        │        assembled fresh, every turn          │
        │   instructions · tools · skills · models    │
        └─────────────────────────────────────────────┘

This article walks the whole pipeline: how the instance is constructed, which config keys shape it, where a customization belongs (instructions vs ANNOUNCE), how users narrow it from the chat composer, and — honestly — what each customization costs in prompt cache.

The whole idea in one line

One config surface defines the agent; the user tunes within it; the platform keeps the result never wider than granted and priced honestly.

The config declares three things:

what the agent knows      instructions + additional_instructions
what the agent may use    the tools/skills inventory (surfaces.as_consumer)
what the agent runs on    supported_models + role_models

Everything the user can later toggle is a subset of that grant. Nothing a user does can enable a tool, a skill, a namespace, or a model the administrator did not configure.

The construction pipeline

Every app workflow that subclasses BaseWorkflow follows the same shape; the reference implementation is the workspace app's react node.

THE CONSTRUCTION PIPELINE — ASSEMBLED FRESH, EVERY TURN turn arrives 1 · tool inventory from config agent_tool_config_from_bundle_props 2 · skill inventory from config agent_skill_config_from_bundle_props 3 · user selection narrows deny-list + model pick apply_user_agent_selection 4 · claims narrow unmet accounts drop for the turn apply_delegated_tool_claims the two per-turn narrowing passes same narrower · fail open [INACTIVE TOOLS THIS TURN] facts go to ANNOUNCE — the cached text stays intact 5 · compose instructions durable, cacheable teaching additional_instructions + memory + namespace roster 6 · build_react(…) → the runtime instance 7 · react.run — the loop each round renders instructions + timeline + the uncached ANNOUNCE tail
Assembled fresh, every turn — with the two per-turn narrowing passes reporting to ANNOUNCE, never rewriting the cached text.
turn arrives (runtime_ctx: tenant/project/user_id/conversation_id/
              turn_id/bundle_id/agent_id, iteration budget, …)
  │
  ├─ 1. agent_tool_config_from_bundle_props(bundle_props, agent_id)
  │       → the agent's TOOL inventory: python tool groups, MCP servers,
  │         named-service namespaces, traits, claim policies
  │         (from surfaces.as_consumer.agents.<id>.tools)
  │
  ├─ 2. agent_skill_config_from_bundle_props(bundle_props, agent_id)
  │       → the agent's SKILL inventory: custom_root + visibility
  │         (from surfaces.as_consumer.agents.<id>.skills)
  │
  ├─ 3. apply_user_agent_selection(tool_config, skill_config)
  │       → the user's saved deny-list narrows both configs;
  │         the user's model pick lands on runtime_ctx.agent_role_models
  │         (fail-open, per turn)
  │
  ├─ 4. apply_delegated_tool_claims(tool_config)
  │       → tools whose connected-account claims are unmet DROP for
  │         this turn; the facts flow to ANNOUNCE
  │         (runtime_ctx.inactive_tools)
  │
  ├─ 5. compose additional_instructions
  │       → config teaching + memory teaching + the named-service
  │         roster — the durable, cacheable text
  │
  ├─ 6. build_react(…)
  │       → the runtime instance
  │
  └─ 7. react.run(…)
          → the loop; each decision round renders instructions
            + timeline + the uncached ANNOUNCE tail

Two things are worth pausing on.

Steps 3 and 4 are the per-turn narrowing passes. Both use the same pure narrower, both fail open (any error keeps the configured set — a broken selection row never yields a broken agent), and both report their effects through ANNOUNCE rather than by rewriting the cached instruction text. That last clause is the article's recurring theme; the "Instructions vs ANNOUNCE" section makes it precise.

Model resolution is lazy, per model call. The runtime binds runtime_ctx.agent_role_models into each invocation's role_models overlay, and the model router resolves role → {provider, model} with the overlay beating the app-level role_models. The user's model pick travels exactly this channel — it is an overlay on one role, applied at call time, not a rewrite of the agent.

The per-agent config surface

Two config roots shape one agent. Both resolve through the same agent-key chain — the agent's own key first, then default_agent, then default — so every setting can be declared per agent or once as the default.

config.react.<agent-key> — behavior and teaching.
KeyShapes
additional_instructionsDurable teaching text appended to the system instruction.
instructionsFull replacement or extension of the instruction composition when the app builds it in code (via build_react's instruction_body / instruction_blocks).
supported_modelsThe admin-allowed model list users pick from. Rows are model / provider / label — the economics price-table naming, so every allowed model is one the platform accounts for.
role_modelsPer-agent role→model mapping, overriding the app-level config.role_models for this agent's runs.
max_iterationsBase decision/tool-round budget.
render_thinking, debug_timeline, …Runtime switches.
surfaces.as_consumer.agents.<id> — the inventory.
KeyShapes
toolsConnections: python tool groups (module/ref, alias, allowed, tool_traits, tool_claims), MCP servers (server_id, allowed), named-service namespaces (namespaces.<ns>.allowed operations).
skillscustom_root for app-local skills + per-consumer visibility patterns.
event_sourcesNamed-service event/pull policies feeding the timeline.

The second root is the agent's inventory — the administrator's grant of everything the agent may use. It is also, unchanged, the menu the user will later pick from: the platform derives the pickable catalog from this same config rather than from a second list that could drift.

One agent, both roots:

config:
  react:
    default_agent:
      max_iterations: 15
      additional_instructions: |
        [HOUSE STYLE]
        Cite sources as browsable URLs.
      supported_models:
        - model: claude-sonnet-4-6
          provider: anthropic
          label: Sonnet 4.6
        - model: claude-haiku-4-5-20251001
          provider: anthropic
          label: Haiku 4.5
  surfaces:
    as_consumer:
      default_agent: main
      agents:
        main:
          tools:
            - name: io
              kind: python
              module: kdcube_ai_app.apps.chat.sdk.tools.io_tools
              alias: io_tools
              allowed: [tool_call]
            - name: web
              kind: python
              module: kdcube_ai_app.apps.chat.sdk.tools.web_tools
              alias: web_tools
              allowed: [web_search, web_fetch]
          skills:
            custom_root: skills
            consumers: {}

Instructions vs ANNOUNCE: where a customization belongs

The rendered context has two homes for app- and platform-supplied text, with opposite lifecycles:

TWO HOMES, OPPOSITE LIFECYCLES SYSTEM INSTRUCTION durable · cached teaching — what the agent always knows house style · domain rules tool guidance · skill gallery named-service roster APP HOOK additional_instructions instruction_body / instruction_blocks nearly free after the first turn ANNOUNCE per round · never cached state — what is true right now [BUDGET] [RUNTIME LIMITS] [USER MEMORY HOTSET] [WORKSPACE] [INACTIVE TOOLS THIS TURN] ← unmet claims land here APP HOOK RuntimeCtx · memory_hotset · inactive_tools volatile by design — costs nothing in cache the lifecycle test — can it change between turns without an admin changing config? → ANNOUNCE the instruction text stays byte-identical, so the cached slice survives.
The lifecycle test: changes between turns without an admin? → ANNOUNCE. Stable teaching? → instructions, where caching makes it nearly free.

The placement rule is the lifecycle test: if the content can change between turns without the admin changing config — connected accounts, per-turn limits, live events, user toggles — it belongs in ANNOUNCE, because putting it in the instruction text would rewrite (and thus invalidate) the cached slice the moment it changes. If it is stable teaching the agent should always know, it belongs in the instructions, where caching makes it nearly free after the first turn.

A worked example. When a tool's connected-account claims are unmet — say the app grants Slack tools but this user has no Slack account connected — the pipeline drops those tools for the turn and publishes the facts on runtime_ctx.inactive_tools, rendered as [INACTIVE TOOLS THIS TURN] in ANNOUNCE. The instruction text stays byte-identical whether or not the account is connected — so the cache survives, and the agent still knows exactly which tools are out and why. (An earlier design wrote this notice into additional_instructions; it was moved to ANNOUNCE precisely because claim status changes between turns and was invalidating the cached slice.)

The per-user layer: what the user tunes

On top of the admin-granted inventory, each signed-in user narrows what THE agent uses for THEM. The selection is stored per (user, app id, agent) and applied in step 3 of the pipeline — from the next message, with no session reset.

Two operations on the SDK entrypoint base serve every chat app:

  • agent_capabilities — returns the pickable inventory: python tool groups with per-tool docs, MCP servers (per-tool entries when the listing is knowable), named-service namespaces, concrete skills with front-matter, supported_models plus the configured default_model — and the caller's current selection.
  • agent_selection_update — merge-writes partial toggles, clamped against the live inventory on every write.

The record is a deny-list plus one pick:

{
  "schema_version": 1,
  "disabled": {
    "tools": {"gmail": true, "web_tools": ["web_fetch"]},
    "mcp": {"knowledge": ["kb_fetch"]},
    "named_services": {"task": true},
    "skills": ["public.docx-press"]
  },
  "model": {"provider": "anthropic", "model": "claude-haiku-4-5-20251001"}
}

The semantics are enforced at both ends — clamp on write, and on read effective = configured − disabled:

  • The user can only remove. Nothing outside the configured inventory can ever be enabled, and new config entries default ON for everyone.
  • System tool groups (io, context) are locked on and immune.
  • Python groups toggle whole or per tool; MCP servers toggle whole or per tool; named-service namespaces toggle whole — a denied namespace also vanishes from the agent's namespace roster and from operation dispatch for the turn. Denied skills disappear from every skill consumer, and a skill whose required tool was denied auto-hides.
  • The model field is a pick, not a denial: one choice from supported_models, applied to the strong decision role for the user's turns, overriding what role_models configures for it. No pick — or a stale pick no longer in the list — runs the configured default.
  • The whole layer fails open: a missing row, a store error, or a stale entry yields the configured behavior, never a broken agent.

In the chat UI this is the composer "+" menu, next to the attachment button: Model (radio pick, the configured default tagged), Skills, Tools (two-level per-tool rows), MCP servers, Services (namespaces), plus a host-gated Connection-Hub entry. It loads lazily from agent_capabilities on first open and saves through debounced agent_selection_update merge-writes.

THE GRANT, THE TUNE, THE TURN admin grants — config TOOLS io · context (locked on) web · gmail · slack SKILLS 9 configured MODELS 2 allowed (supported_models) the inventory user tunes — selection DENY gmail (whole group) web_fetch (one tool) 1 skill PICK Haiku 4.5 remove-only = the turn runs with TOOLS io · context · slack web (− web_fetch) SKILLS 8 MODEL Haiku 4.5 applied from the next message never wider than granted — clamp on write · effective = configured − disabled on read system groups locked on · the whole layer fails open — a broken selection never yields a broken agent.
The grant ∩ the tune = the turn: only removals and one pick — never wider than granted.

What a switch costs: cache economics, honestly

Per-user customization interacts directly with prompt caching, and the costs differ sharply by category. The platform is deliberate about naming them.

Switching the model destroys the prompt cache completely. Provider prompt caches are per model. The first turn on the newly picked model pays full input cost for the entire context — instructions, tool catalog, the whole visible timeline — exactly as a brand-new conversation would. The consequence is billing: that turn is charged at full input rates while the cache rebuilds, and caching resumes from that turn on.

Toggling tools or skills invalidates the cached prompt slice. The tool catalog and skill gallery render inside the system prompt, so adding or removing a tool group, an MCP server, a namespace, or a skill changes that text. The next turn re-writes the cache from that point — one cold turn on the same model, then caching resumes.

Turn-local state is free. Everything routed through ANNOUNCE — the inactive-tools notice, the memory hotset, budget and limits — changes nothing in the cached slice. That is precisely why the lifecycle rule exists: the design keeps the volatile out of the cacheable.

WHAT A SWITCH COSTS — ACTION → CACHE EFFECT → BILLING model pick changed provider caches are per model cache destroyed the entire context re-caches full input rates — entire context instructions · catalog · whole timeline tool / skill / namespace toggle the catalog renders in-prompt cached slice re-written from the changed text onward one cold turn same model — then caching resumes ANNOUNCE-routed state inactive tools · hotset · budget no cache change the volatile lives outside the slice warm as usual this is why the lifecycle rule exists a cold turn is charged at full input rates while the cache rebuilds; warm turns reuse it — switch warnings and an operator cold-cache policy are the planned next step.
Action → cache effect → billing: the expensive switches are named, the volatile is free by design.

Two platform behaviors build on this honesty: the chat surface will warn the user before a cache-destroying switch (model change, tool/skill toggles), and a configurable cold-cache policy — letting an operator bound how often users may force cold turns — is the planned next enforcement step.

How the chat component connects

The chat engine carries the agent identity and the selection UI end to end:

  • EngineConfig.agentId (default main) rides every message target and event batch and scopes the selection operations — one chat instance drives one configured agent. Pointing a second chat instance at a second configured agent is the same wiring with a different id.
  • The composer "+" menu is fed by agent_capabilities (lazy, on first open) and writes toggles through debounced agent_selection_update merge-writes, flushed on send.
  • Toggles apply from the next message: the backend reads the saved selection per turn, so there is no session invalidation — the next turn is simply built from the updated selection, with the cache cost from the previous section when the toggle touched the cached slice.

The payoff

Creating a ReAct agent is a declaration, and the declaration is the contract:

  • One config surface defines the agent — its teaching, its inventory, its allowed models — resolved per agent with sane defaults.
  • The user tunes within it — remove-only toggles and one model pick, from the composer, applied next message.
  • The platform keeps it safe — clamp on write, configured − disabled on read, system groups locked, unmet-claim tools dropped per turn, everything failing open — so the effective agent is never wider than granted and never broken by its own settings.
  • And priced honestly — every customization has a named cache consequence, the volatile lives in ANNOUNCE where it costs nothing, and the expensive switches are about to come with warnings and an operator-set policy.

The agent is not a snowflake object an app must nurse. It is the config, run.

KDCube Deep · 06.07.2026