Create A ReAct Agent
An app creates a ReAct agent mostly by declaring it: one config surface defines the agent's teaching, its tools/skills inventory, and its allowed models; the user tunes within it from the chat composer; and the platform keeps the result never wider than granted — with every customization's prompt-cache cost named honestly.
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.
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.
| Key | Shapes |
|---|---|
additional_instructions | Durable teaching text appended to the system instruction. |
instructions | Full replacement or extension of the instruction composition when the app builds it in code (via build_react's instruction_body / instruction_blocks). |
supported_models | The 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_models | Per-agent role→model mapping, overriding the app-level config.role_models for this agent's runs. |
max_iterations | Base decision/tool-round budget. |
render_thinking, debug_timeline, … | Runtime switches. |
| Key | Shapes |
|---|---|
tools | Connections: python tool groups (module/ref, alias, allowed, tool_traits, tool_claims), MCP servers (server_id, allowed), named-service namespaces (namespaces.<ns>.allowed operations). |
skills | custom_root for app-local skills + per-consumer visibility patterns. |
event_sources | Named-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:
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_modelsplus the configureddefault_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
modelfield is a pick, not a denial: one choice fromsupported_models, applied to the strong decision role for the user's turns, overriding whatrole_modelsconfigures 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.
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.
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(defaultmain) 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 debouncedagent_selection_updatemerge-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 − disabledon 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.
Documentation on GitHub
The live docs behind this entry: