The capability catalog¶
Everything an agent can do comes from one of two places: a capability registered in this deployment's code, or an MCP server somebody connected. This page is the first list.
A capability is the unit worth switching on or off — one line in the Builder, one
entry in the spec. It is deliberately not "a tool": knowledge search is a single
decision for the person configuring an agent, and whether it exposes one function
today and three next month is not their problem. Capabilities also cover things
that are not tools at all, which is why thinking and clock are here with no
tools listed.
The API is authoritative, this page is a snapshot
GET /api/v1/agents/capabilities serves the registry as it is in the running
deployment, including anything added since this page was written. The Builder
renders its picker and its configuration forms from that response. If the two
disagree, the API is right.
What ships¶
| id | Name | Category | Tools | Scope | Key |
|---|---|---|---|---|---|
knowledge |
Knowledge search | knowledge | search_documents |
knowledge:read |
— |
skills |
Skills | knowledge | read_skill_resource |
knowledge:read |
— |
context |
Context | knowledge | list_context, read_context |
— | — |
memory_files |
Memory files | knowledge | list_memory, read_memory, write_memory, edit_memory, delete_memory |
— | — |
memory_mem0 |
Memory (mem0) | knowledge | remember, recall |
— | required |
conversation_search |
Conversation search | knowledge | search_conversations, read_conversation |
conversations:read |
— |
web_research |
Web search | research | web_search |
web:read |
for paid services |
web_fetch |
Web fetch | research | web_fetch |
web:fetch |
— |
browser_choice |
Browser automation (choose) | research | browse_page |
web:browse |
via the browser extra |
browser_use |
Browser automation | research | browse_web |
web:browse |
via the browser-use extra |
code_execution |
Run Python | analysis | run_python |
code:execute |
— |
sandbox |
Files & shell | analysis | ls, read_file, glob, grep, write_file, edit_file, execute |
sandbox:execute |
for Daytona |
charts |
Charts | analysis | create_chart |
— | — |
image_generation |
Image generation | analysis | generate_image |
— | required |
subagents |
Delegation | reasoning | task, check_task, wait_tasks, list_active_tasks, answer_subagent, send_message_to_subagent, soft_cancel_task, hard_cancel_task, create_agent, delegate |
agents:delegate |
— |
planning |
Planning | reasoning | write_plan, read_plan, add_task, update_task_status, update_task_statuses, remove_task, add_subtask, set_dependency, get_available_tasks |
— | — |
thinking |
Thinking | reasoning | none, by design | — | — |
system_reminders |
System reminders | reasoning | none, by design | — | — |
tool_search |
Tool search | utility | none, by design | — | — |
clock |
Date and time | utility | none, by design | — | — |
guardrails |
Guardrails | utility | none, by design | — | — |
compaction |
Context management | utility | none, by design | — | — |
media |
Media offload | utility | none, by design | — | — |
tool_output_limits |
Tool output limits | utility | read_tool_result |
— | — |
channel_tools |
Chat channel lookup | channels | get_channel_info, list_channel_members, search_channels, read_channel_history |
— | — |
Seven of those have no tools on purpose. thinking changes how the model runs
rather than what it can reach, clock puts the date in the instructions,
tool_search contributes its search function only once it wraps a toolset that
has deferred tools — in isolation it declares nothing — guardrails inspects and
rewrites the text flowing through a run, compaction rewrites the history a
request carries, media rewrites what a compacted history is stored as, and
system_reminders appends steering text to the request tail.
None of the seven leaves anything for a person to approve, so none
declares a tool. A capability with genuinely no
tools says so with tools=() rather than omitting the argument; see
Add a capability.
This column is what a capability declares, which is not always what a model is
offered. Delegation is the one place the two differ: create_agent and delegate
appear only under allow_dynamic, and answer_subagent appears to nobody at all —
both explained under Delegation below.
One of them is not in the Toolbox at all. channel_tools is chosen per bound
bot under Where this agent is available, and publishing refuses a spec that
tries to carry it — see Chat channel lookup.
Knowledge search¶
search_documents — Search the organization's documents for passages relevant to
a question.
Searches the collections the agent's spec binds and cites what it used. The model asks what to search, never where: collections are resolved from the spec before the run and handed to the capability, so an agent cannot reach a collection nobody connected to it.
| Config | Default | Range |
|---|---|---|
default_top_k |
5 | 1–50 |
default_top_k applies only when the model does not ask for a number itself.
Bound with no collections, this capability contributes nothing — it is not attached at all. A search tool that always returns empty is worse than no search tool, because the model keeps trying it and reasons from the silence.
Skills¶
read_skill_resource
Written know-how the agent loads only when it decides it is relevant, one skill at a time — the alternative being an instructions field that grows until every run pays for every procedure. See Skills for what a skill is and how one gets into an organization.
Each bound skill is a capability of its own. Its name and description sit in
the catalog the model reads every turn, and the model pulls the body in with
load_capability — the agent framework's own tool, which is why it is not in the
list above and why a spec cannot grant, gate or rename it. read_skill_resource
is the one tool this capability contributes, and it appears only when at least one
bound skill ships a file beside its instructions.
The tool comes from pydantic-ai-skills, so its name and wording are somebody
else's to change. A drift test compares what the registry declares against the
tools the model is actually offered, which is what reports the day that happens.
run_skill_script is switched off rather than exposed: a skill's files reach a
run under /workspace/skills/, where the sandbox's own execute
runs them under the operator's ceilings, and a second execution path would be a
second set of rules to get wrong.
Context¶
list_context, read_context
An organization's standing context put into the run instead of made to be asked
for — a glossary, a brand voice, an escalation matrix. Each bound file carries a
mode: an inject file is spliced into the instructions verbatim, so the model
simply knows it; a link file is left out of the prompt and reached through
read_context, so a large or rarely-needed file costs nothing until the model
decides it is relevant. list_context reports what is available without the
bodies. The two tools appear only when a link-mode file is bound; an
agent whose files are all inject contributes instructions and no tools.
Injected content is framed as reference material — delimited and prefaced with a
line telling the model to treat it as information, not as instructions — because
a file's body is written by a person and reaches the model verbatim. The fence is
best-effort against accidental breakout: a body that itself contains a closing
</context-file> or </context-files> tag, or a name or format holding a ",
is neutralised so it cannot spill text back into the trusted instructions. It is
not a security boundary — a context:edit holder can still inject deliberately.
Content is text: a document to be searched belongs in a knowledge collection, not
here.
Bound with nothing usable — no files, or only link files with the read tool
turned off — this capability contributes nothing and is not attached, the
same way knowledge bound to no collections is not. Files are managed under
/api/v1/context and bound to an agent by id (AgentSpec.context_ids).
Memory files¶
list_memory, read_memory, write_memory, edit_memory, delete_memory
Notes an agent keeps of its own across conversations, indexed by one it maintains
itself. Where context is a library a person authors and binds to many agents,
memory is the agent's own: it writes through tools mid-run, and nobody else writes
here at all. It is not bound by id — enabling the capability gives the agent its
notes.
MEMORY.md is the index, and it is shown to the agent every request. It is an
ordinary note the agent writes and edits with the same tools as any other, and the
capability splices it into the instructions the way a bound context file is
spliced in. So the agent meets what it has saved before it decides anything, and
opens a listed note with read_memory when the line says it is worth reading —
rather than having to decide to call a listing tool a lighter model rarely calls.
Whose notes, and who may hear them¶
A note belongs either to one person or to one group chat, and a run touches exactly one store: the conversation's own. Which one is derived server-side from who will hear the answer, never from the model — so no tool takes a scope and there is nothing for the agent to get wrong.
- One to one — web chat, the HTTP API, a direct message — the notes are that person's, and nobody else ever reads them. The same person reaches one store from all three: a linked chat account resolves to their account rather than to the surface they arrived on.
- In a group chat the notes are the chat's, and everyone in the chat reads them. The speaker's own notes are not reachable there: something written down alone with somebody is not read back aloud where a whole channel sees it.
- On a public widget or an embed there is nobody to attribute anything to, so there is no store, and the tools say so rather than saving somewhere.
There is no organisation-wide store. One existed and was removed: it was a second mechanism for what context files already do — standing knowledge a person authors and binds to agents — and one job with two mechanisms is how the two disagree. Memory is what the agent learned; anything a human writes belongs in context.
One switch, Allow personal memory, drops the per-person store entirely for compliance or privacy; the notes kept in group chats stay.
What is injected, and what is only fetched¶
A tool result is something a model weighs; the instructions are what it obeys. So
the index reaches the prompt only where its content could have steered nobody but
the reader: in a one-to-one conversation it is injected, and in a group chat it
stays reachable with read_memory and is never injected. A room's notes are
self-scoped to nobody, so one colleague's sentence would otherwise arrive as
another colleague's instructions in the same channel.
An index larger than about 6,000 characters is left out rather than cut. Half an index — ending mid-line, mid-filename — is worse than none.
Reading it, and erasing it¶
Nobody browses somebody else's notes by organization role. That was the whole of the earlier answer - erasure and no listing at all - and it was half right: a listing is a surveillance affordance of a colleague's store, and the opposite of one of your own. So the answer now has three parts (#1594).
Your own, always, at Settings → Memory. No permission gates it, because the answer is the same for a Viewer and an Owner: what agents here have written down about you, across every agent, with which one wrote each note and when. Three things you can do to a note:
| Stop using it | The note is no longer listed, read or editable by any tool, so it stops reaching the model - and it still exists, for you to look at and restore. The middle answer, for a note that is wrong or too personal and that you are not yet sure you want gone. |
| Use it again | Restores it. |
| Delete it | Gone. |
A suppressed note's name is still taken, so an agent writing that name again revives the row with the new content. That is not the suppression being undone: what you suppressed is overwritten, and the row holds something the agent has learned since. The alternative - a name permanently unusable - is a store that silently refuses to work and never says why.
The index follows. MEMORY.md is spliced into the instructions of every
request, so a note you stopped whose index line still describes it is a note
still reaching the model. Suppressing or deleting a note therefore drops the
index lines that name it. A line that describes the note without naming it
survives - the pruning is line-level and keyed on the name, because that is what
the index is - and restoring a note does not put a line back: the agent writes
the index, and this is not the place to author prose in its voice.
Somebody else's, only a deployment administrator. GET /memory/person/{id},
naming the tenant, and refused to everybody else: not an Owner, not an Admin, not
somebody holding an edit grant on the agent that wrote the note. The reasoning is
the one above - an organization role is not the party a subject-access request
reaches, and the deployment's own administrator, who already administers accounts
across tenants, is. The read is recorded in the audit trail with the actor, the
tenant, the subject and a reason, and no content: an entry holding what it
looked at would be a second copy of the thing being protected.
Erasure, unchanged. A person clears everything an agent remembers about them
from their own profile, and an administrator holding members:manage can do it
for somebody else; both delete the rows here and the matching memories in
mem0 for every agent that binds it. Clearing one agent's memory entirely is in
its toolbox, beside the capability.
What the self-service view does not reach. An agent bound to mem0 keeps its memories in somebody else's service, and this page does not list them - mem0's API answers what a question matches, not what a store holds. Those agents are named on the page rather than left out, because a list of native notes presented as a complete inventory would be worse than one that says what it misses. Erasure does reach mem0; reading does not.
Memory (mem0)¶
remember, recall
Semantic memory kept in a mem0 service — cloud, or self-hosted
via base_url — rather than in this deployment. remember keeps a short
self-contained sentence; recall finds the ones a question is about by meaning
rather than by name. It needs an API key from the organisation's vault.
Which memories a run can reach obeys exactly the rule above, because mem0 is
handed the whole scope as its user_id: {org}:{agent}:{owner}. One mem0 account
therefore cannot mix two organisations', two agents', or two people's memories.
Two differences worth knowing before choosing it. Nothing is stored here, so erasing a person's memory reaches mem0 through its own API rather than through a row we delete. And mem0 bills its own embedding out of band, so the deployment's spend ledger does not see it and a budget cap does not bound it.
A self-hosted base_url must be https and on MEM0_ALLOWED_HOSTS. An empty
allowlist refuses self-hosted mem0 outright, which is deliberate: the key travels
in an Authorization header, so a builder who may bind but not read a shared key
must not be able to point it at a server of their own.
Conversation search¶
search_conversations, read_conversation
Finds a past conversation by what was said in it, and opens it in full. Memory can only recall what some earlier turn thought worth writing down; everything else was said, stored, and until this existed unreachable — so "what did we decide about the Q3 pricing" answered "I have no record of that" in a product holding the whole exchange.
search_conversations returns the best-matching threads, each with its title, when
it was last active, how many turns matched, and the strongest passage with the
matched words in bold. read_conversation opens one as Markdown, split into
USER: and AI: in the order the turns were written, naming who spoke where a
room has several people in it. A long thread comes a window at a time and the
answer says how to ask for the next.
Whose conversations¶
One person's: the person the run is answering. Three ways in, the same three the console allows — conversations they own, conversations shared with them, and channel threads they took part in and are still a member of, confirmed against the chat platform. A trigger's run-log is deliberately not among them: it is a transcript of runs made under somebody else's authority, and an agent searching on a person's behalf holds no permission of theirs to check it with.
And only where that person is the only listener. In a group chat both tools refuse, saying why: the corpus is personal, so answering from it in a channel would read one person's private conversations out to everyone in the room. It is the line the memory index draws, one layer further out.
How it matches¶
PostgreSQL full-text search — a tsvector maintained by the database over every
message, a GIN index, websearch_to_tsquery for the query and ts_rank_cd for
the order. Quoted "exact phrases", or, and a leading - to exclude a word all
work. Not ILIKE, which matches inside words and cannot rank; not embeddings,
which is what knowledge search already is and answers a
different question.
Words are matched whole and case-folded but not stemmed: meeting does not
find meetings. The configuration is fixed in the database and english would
stem one language while mangling every other — PostgreSQL ships no Polish
dictionary at all — so evenness across languages is bought at the price of word
forms. The tool description states it, so a model that finds nothing tries another
form of the word rather than concluding nothing was said.
An operator who does not want agents reading conversations at all withholds the
conversations:read scope, which turns it off across the whole deployment. There
is no setting that widens the corpus.
Web search¶
web_search — Search the public web for current information.
| Config | Default | Values |
|---|---|---|
method |
duckduckgo |
duckduckgo, native, tavily, brave, exa |
max_results |
5 | 1–10, ignored by native |
The console names each method rather than printing the value it stores, and draws
the service's own mark beside it: the field carries x-enum-labels, which is what
the generated form reads for a label. Without them the picker offered
duckduckgo and exa in the case they are stored in, which reads as a config key
to recognise rather than a product to choose.
duckduckgo— free, no account, results rendered as clickable sources.native— the model provider searches with its own index and returns its own citations. Only on models that support it.tavily— results summarised for a model to read.brave— an index of its own.exa— search by meaning rather than by keyword.
The three paid methods need an API key from the organization's
secrets, named by the binding's secret_id. The requirement is
conditional rather than flat: a flat one would either lock the free default behind
an account, or let a Tavily agent publish with nothing to authenticate with and
fail on its first search.
Approval and native do not combine, for the reason
Web fetch gives below: the approval gate wraps
tool execution, and a native search is executed by the model provider, so a
binding that requires approval for web_search and sets method to native is
refused at publish rather than given a gate that never fires. Choose a method
this deployment runs itself, or drop the approval requirement.
Search finds a page; it does not read one. Reading is Web fetch below, and it is a separate capability with a separate scope.
Web fetch¶
web_fetch — Read the full page at a URL, as Markdown.
| Config | Default | Values |
|---|---|---|
method |
local |
local, native, auto |
max_content_chars |
50000 | 1000–200000, ignored by native |
allowed_domains |
— | bare hostnames the agent may fetch; null means any |
blocked_domains |
— | bare hostnames it may never fetch |
local— this deployment fetches the page. The default, because it is the only method that behaves identically on every model.native— the model provider fetches it, with its own egress and its own citations. Only on models that support it; Pydantic AI raises on the rest.auto— native where the model has it,localeverywhere else. Exactly one of the two is ever offered, so a run cannot pick between them per call.
The fetch itself is Pydantic AI's web_fetch_tool over its SSRF-guarded
safe_download, and that is the reason this is not code of ours.
The URL comes from the model and is dereferenced from inside the container, so
validating it up front — the way app.core.sanitize.validate_webhook_url does for a
callback somebody handed us — is not sufficient on its own. httpx resolves the
hostname a second time and follows redirects without asking again, so a name that
answered publicly a moment ago can answer 169.254.169.254 now, and a public URL
can redirect to one.
safe_download pins the address it resolved into the request and re-validates every
hop, the domain filters included. It also bounds the body as it streams, and refuses
the compression encodings that cannot be bounded that way.
Private, loopback, link-local and cloud-metadata addresses are refused, and the refusal reaches the model as a retryable error rather than as an empty page — a refusal shaped like a result is one the model answers around without saying it had to. The library can be told to permit local addresses; nothing here exposes that.
The domain filters are not the security boundary — safe_download is
They match the hostname exactly, with no wildcards and no implicit subdomains, so they answer which sites may this agent read and not can this agent reach our network.
An entry that could never match is refused at publish: a wildcard, a scheme, a path, a port, or an empty allowlist. Each would otherwise leave a denylist quietly not denying, or an allowlist quietly denying everything.
An empty denylist denies nothing, which is what leaving it unset already means, so
it is read as unset rather than refused — an imported spec that spells "no denied
hosts" as [] is saying something true.
An entry that can match is stored in the single spelling DNS would be asked for:
lower case, no trailing root label, IDNA-encoded. A name has more than one
spelling, and an exact match against one of them is a filter with a hole in it —
https://exämple.com/ reaches the comparison as typed, so a denylist holding only
xn--exmple-cua.com would let it through while getaddrinfo resolves the two
identically. Every equivalent spelling is handed to the filter at build time; the
spec stores one.
Approval and native do not combine
The approval gate wraps tool execution, which is the only place a call can be held — so a fetch the model provider runs on its own side never reaches it.
A binding that requires approval for web_fetch and sets method to native — or
to auto, where which of the two runs is a property of the model profile and
changes without republishing — is refused at publish, rather than given a gate
that silently never fires.
Set method to local, or drop the approval requirement. Both are legitimate
agents, and which one is wanted is not a decision to make on the author's behalf.
A version published before that refusal existed is refused again when it is assembled, because nothing re-validates a frozen version. So such an agent stops running until it is edited, rather than going on fetching unapproved.
A page arrives as Markdown, truncated at max_content_chars; a PDF or an image
arrives as binary content the model reads natively. Nothing summarises it — what
to do with a page belongs to the agent's instructions.
Browser automation (choose)¶
browse_page — Work through a web page towards a goal, one chosen action at a time.
A goal and a starting URL. It opens the page in a Chromium you run and then repeats three things: read the page into a numbered table of the elements a person could act on, ask a decision model which operation and which element, carry that out. Only typing a field's value reaches a language model.
Reach for it when a page has to be operated — a form, a filter, a consent gate, a multi-step flow, a search whose results need a click. For a page you only need to read, web fetch is faster and has no side effects.
It chooses; it does not compose. A browser agent that writes its next action can emit any string, so the page's text is an instruction channel into the model and the only defence is telling the model not to listen. This one answers a pick-one whose options are built on the server from the live DOM, so a page cannot offer an action by describing one.
That is not the same as safe. "Delete account" is an action a page genuinely offers,
so the capability is side_effecting and browse_page can be put behind
approval.
It is not held for approval by default, which is the one place the tool's flag
disagrees with the capability's. An approval on a browse arrives before the first
page is fetched, on a goal in natural language and a URL — so it asks somebody to
approve actions nobody can see yet, which is consent without information. What
replaces it is watching: the console draws the browse while it runs, every step
names what was chosen and how sure the engine was, and allowed_domains bounds
where it can go at all. An operator who wants the gate sets tool_approval on the
binding, which wins over this; min_confidence is the automatic version of the same
instinct.
It reports being blocked. A sign-in wall, a consent gate, a captcha, a page that does not contain what was asked for: the engine says so, and the browse ends with an outcome rather than with silence at the step ceiling. Four outcomes, and all four are ordinary — finished, blocked by the page, stopped at the step limit, and the browser could not be reached.
| Config | Default | Values |
|---|---|---|
cdp_url |
the one allowed host, where there is one | a Chromium DevTools endpoint; required, and its host must be on BROWSER_CDP_ALLOWED_HOSTS. With exactly one host allowed the form arrives filled in |
allowed_domains |
null | hosts the browser may be on; globs like *.example.com allowed; null is unrestricted |
decision_model |
jev-latest |
a picker over app/core/catalog/decision_models.json; a pinned build such as jev-1.13.0 can be typed, because an agent whose confidence floor was tuned against a version needs one |
decision_base_url |
null | where that model runs. Empty is the vendor's own https://api.typesafe.ai, which is where page content goes unless this says otherwise |
max_steps |
25 | 1–100; each step is one decision request |
candidate_cap |
60 | 2–200; how many elements may be offered as choices in one step |
min_confidence |
0.0 | 0–1; refuse to act on a pick scored below this, ending the browse as blocked |
preview |
true |
send the viewport to the chat while the browse runs |
preview_width |
1024 | 320–1920; how wide those frames are |
The browser is one you run, and the operator says which. There is no local
mode and no Chromium in the API image: cdp_url points at a browser service an
operator runs and isolates. The host must be on
BROWSER_CDP_ALLOWED_HOSTS, and an empty allowlist — the
default — refuses browser automation outright. Checked at publish, when the
spec is saved, rather than on every run.
That is an allowlist rather than the SSRF check every other tenant-supplied URL
goes through, and the reason is what cdp_url is: it lives in a spec, which
anyone holding edit on the agent writes, so the address is tenant-controlled and
the request is this deployment's. The SSRF guard is wrong for it in both
directions — it admits only public addresses, so it refuses the isolated service
on your own network that this page tells you to run, and it accepts a CDP debugger
exposed to the internet, which is the worse posture of the two. A vetted host
needs no address check; an unvetted one is refused whatever it resolves to.
Each browse gets a browser context of its own, disposed when it ends. On a long-lived browser shared by many callers, the default context would keep a cookie set when one person's agent signed in — and the next caller of the same agent would arrive already authenticated as them. Closing the tab does not clear that; disposing the context does.
Every step sends the page to the decision model. Its URL, its title, its
element labels and a bounded excerpt of its visible text — which on the vendor's
public endpoint is a third party, and may be the contents of an internal system.
The text is there because without it the engine cannot tell that it has finished:
a price, a confirmation and "no results" are ordinary text rather than controls, so
DONE would be a guess. A value the agent types is deliberately not sent — the step
is recorded as "filled" without it, so a password does not travel to that endpoint.
Two things make that a decision rather than an accident: the capability requires an
API key from this deployment's vault, so it cannot run until an operator adds one,
and decision_base_url points the decision model somewhere else — at a host on
DECISION_MODEL_ALLOWED_HOSTS, because that field is in the
spec and the key is unsealed into a header to whatever it names. An empty allowlist,
the default, permits only the vendor's endpoint. See what leaves the deployment.
Both model paths are metered, and neither is priced. The decision model runs once
per step and the run's own model once per field typed; both book tokens against the
run's budget. A price is another matter: the bundled price snapshot does not know the
decision model, so a browse shows usage and no cost, and a budget denominated in
dollars does not constrain it. max_steps is what bounds a browse.
cdp-use and the TypeSafe SDK arrive with the browser extra, which a default
install does not have. An operator who wants the capability installs
agenticos[browser]; a bound agent whose deployment lacks it fails the one tool
loudly, with the install line.
It streams while it runs. The console draws the viewport, the page it is on, and
each step with the probability the engine found it at — see
the console. preview off keeps the narration and drops the
pictures.
Browser automation¶
browse_web — Delegate an open-ended web task to an autonomous browser agent.
One goal in natural language, handed to a browser-use agent that drives a real Chromium — navigating, reading, clicking, extracting — and returns a text result. Reach for it when the page layout is unknown or the task needs judgement, not for a scripted flow a direct request would do.
This is the largest attack surface a capability opens: a browser follows what a page
tells it, the page is untrusted, and so browse_web turns web content into a tool
with side effects. It is side_effecting and gateable for that reason — put it
behind approval and the injected page reaches a person, not an
action.
| Config | Default | Values |
|---|---|---|
mode |
playwright |
playwright, remote |
cdp_url |
null | a Chromium DevTools endpoint; required by (and only valid in) remote |
allowed_domains |
null | domains the agent may reach; globs like *.example.com allowed; null is unrestricted |
max_steps |
25 | 1–100; each step is one model request |
use_vision |
true |
send page screenshots to the browser agent's model |
headless |
true |
run a locally launched browser without a window (playwright only) |
mode chooses where the browser runs. playwright launches a headless Chromium
next to the agent; remote attaches over CDP to a browser an operator runs
elsewhere. A self-hosted deployment points remote at a hardened, isolated browser
service rather than giving the app container a browser process. A remote cdp_url
is a URL this deployment connects to server-side, so it is SSRF-checked — a loopback,
private, reserved or metadata address is refused at publish, when the spec is
saved, rather than on every run (the check resolves DNS, which must not block the
event loop the run assembles on).
The browser agent's model spend is metered. The sub-agent runs on the host run's model — the one whose credential was resolved from the vault — and each of its steps is one model request, booked against the run's budget through the same ambient-usage ledger a compaction summary uses. It is not browser-use's own hosted model, and it is not spend the budget guard cannot see.
browser-use is an optional extra. It pulls a heavy tree (Chromium via
Playwright) and pins dependencies a minor lower than the rest of the platform, so it
is not installed by default. An operator who wants the capability installs
agenticos[browser-use] and provides a Chromium; a bound agent whose deployment
lacks it fails the one tool loudly, with the install line.
Run Python¶
run_python — Run a small Python program to compute something.
A restricted sandbox with no network and no filesystem, which is why time and memory are the only limits worth setting.
| Config | Default | Range |
|---|---|---|
timeout_secs |
10 | > 0, ≤ 120 |
max_memory_mb |
256 | 16–4096 |
Per agent, not per deployment
An author raising a limit for one data-heavy agent should not need an operator or a redeploy — and the ceilings are capped rather than open-ended.
Files & shell¶
ls, read_file, glob, grep — reading.
write_file, edit_file, execute — writing and running.
A workspace that survives between turns. code_execution computes and forgets;
this remembers, and on a container-backed backend it has a real shell. An agent
granted both computes with one and keeps its work in the other — which is the
normal pairing on the state backend, because that one has no shell at all.
| Config | Default | Values |
|---|---|---|
backend |
state |
state, service |
connection_id |
null | a registered sandbox connection; null takes the organization's default. service only |
session_scope |
conversation |
run, conversation, channel, user, agent |
runtime |
null | an alias that connection's service allows; service only |
include_execute |
true |
removes the shell entirely when off, rather than gating it |
There is no docker or daytona backend to choose. Where a sandbox runs is a
property of the connection an operator registered — Sandboxes in the app — so
naming the connection is naming the kind. Choosing them separately made it
possible to choose two things that disagree.
backend is infrastructure; session_scope is a data-sharing policy. Getting
the first wrong costs a feature. Getting the second wrong shows one person
another person's files, so it is worth reading twice:
| Scope | Who shares the workspace |
|---|---|
run |
Nobody — a fresh one every turn |
conversation |
Everyone in that chat. On Slack a thread is a chat, so threads do not share |
channel |
Every thread in one channel. A direct message has its own chat id, so people still get their own |
user |
One person, across every surface they reach this agent on |
agent |
Everyone who talks to this agent, across the organization |
conversation and channel exist as separate answers because a chat platform
makes them different things. SlackAdapter folds thread_ts into the chat id, so
conversation on Slack means one workspace per thread — fifty threads in a busy
channel is fifty containers and a 429 for the fifty-first person to reply.
The scope in the spec is the default. Each channel the agent is published to
can override it, on the exposure: an agent reached in web chat and on a Slack bot
is one agent in two situations, and one value for both was the wrong shape.
user scope is what carries a workspace across surfaces — the same person picking
up in Slack a conversation they started in web chat finds their files there.
agent is the one that crosses a boundary between people. The Builder warns at
the field, the file panel labels whose workspace it is rather than calling it "this
conversation's files", and setting it is recorded in the audit log — because a user
who sees a file they did not create should be able to find out why.
Changing the backend or the connection starts a fresh workspace rather than
reattaching to the old one. A stored document, a container's volume and a
Daytona sandbox are three different things, and two sandboxd installations are
two different things — so each gets its own workspace, and the previous one stays
where it is, still listed and still readable. Moving a live agent is therefore not
a way to carry its files across; the agent finds an empty workspace on the new
host. Since connection_id: null means "the organization's default", marking a
different connection as default has the same effect without any spec changing.
A spec chooses a connection and never an image, a mount, a network mode or a
ceiling. Those belong to whoever runs the deployment: a spec is authored in a
browser by anyone holding edit on the agent, and one that could name a container
image could name one whose entrypoint mounts the host. runtime is an alias, and
the Builder offers only the aliases that connection's service reports — read live,
because a stored copy would offer one the service has since stopped allowing.
What each backend costs to run:
| Backend | Needs | Shell | Where files live |
|---|---|---|---|
state |
nothing | no | this database, capped at SANDBOX_STATE_MAX_BYTES |
service |
a registered connection | yes | a container on that host, or Daytona's cloud on the organization's own account |
An operator can see what is running: Sandboxes lists this organization's open sandboxes on its default host with their runtimes, idle times and memory, and the activity log per sandbox. See Configuration.
Publishing is refused for a service workspace when the organization has
registered no connection, when the one it names is gone, or when that connection
has no credential — each by name, because all three are states a deployment
reaches after an agent was published and the fix is an operator's rather than the
author's.
Only execute asks. Side-effecting is declared per tool, and of the seven only
running a command is: a workspace is scratch space deleted with the conversation it
belongs to, so writing a file in it is not the class of act sending an email is —
and an agent that has to ask before every write_file cannot do multi-step work at
all, which is how an author ends up turning the gate off entirely and losing the one
that mattered. execute runs arbitrary commands on somebody's host.
A binding that wants the stricter behaviour sets it per tool:
tool_approval: {"write_file": "required"}. See Governance for
how an approval is put to a person — and for the two things one chat session can
say on top of the spec: waive every gated call for this conversation, or ask about
every tool the agent has, including the MCP tools the spec-driven gate deliberately
leaves alone (agenticos#925).
Some paths are refused whatever the approval policy says. Credentials
(**/.env, **/*.pem, **/*.key, **/credentials*, **/.ssh/**, **/.aws/**)
and the system tree (/etc/**, /usr/**, /proc/** and their siblings) cannot be
read, written or edited — the agent gets a readable refusal and can carry on. grep
is filtered rather than refused, since a pattern over / legitimately covers the
workspace: matches inside an off-limits file are dropped, so a search cannot return
a line from one. Names are not secret, so ls and glob still show what is there;
only the contents are withheld.
A command that names one of those paths is refused too, so cat /etc/shadow
does not get round the rule by asking a different tool. That is defence in depth
and not a boundary, and the difference matters: a shell reaches a file in ways
string inspection cannot see, so what actually makes execution safe is the
container's isolation and the operator's network mode. There is no allowlist of
command strings, because one is defeated by sh -c.
And none of it is a substitute for the approval gate: refusal here is the code's
flat no, while execute asking a person is the decision an operator owns.
Files somebody attaches to a message land in /uploads — see
File processing.
Skills become files too. An agent with both a workspace and skills gets each
skill as /workspace/skills/<name>/SKILL.md with its resources beside it, which is what
makes a skill's script runnable at all: it is on disk next to the shell that can
run it. There is deliberately no run_skill_script — execute already has the
approval gate and the operator's ceilings behind it, and a second execution path
would be a second set of rules to get wrong.
Those files are writable, and what the agent writes does not become a skill. A
skill is instructions every agent bound to it follows on every run, so a change is
recorded as a proposal and somebody holding skills:edit accepts or discards it —
see Skills.
Charts¶
create_chart — Draw a chart of numbers you already have, so the user can see
them.
Renders numbers the model already has. It does not fetch, compute or aggregate —
pair it with code_execution or knowledge for that. No configuration.
The numbers arrive as columns — one x_values list for the axis, one values
list per series — because a free-form data argument is not something a JSON Schema
can describe, and a model given an array of objects with no declared properties sent
back a single empty one.
A chart with nothing in it is now unexpressible rather than merely refused. An axis with no points, a chart with no series, or a series holding fewer numbers than the axis has points all come back as a retry naming what is missing.
A frame drawn around no data reads as "there is no trend" rather than as a mistake — and it is persisted, and re-rendered on every replay of the conversation.
Image generation¶
generate_image — Generate an image from a written description.
Draws an image with a dedicated image model — separate from the agent's own — so
it works whatever model the agent runs on. create_chart plots numbers; this
draws pictures.
| Config | Default | Values |
|---|---|---|
provider |
openai |
The providers whose model class honours the image tool and takes an API key - two today |
model |
gpt-image-2 |
That provider's models, from app/core/catalog/image_models.json |
quality |
provider default | low, medium, high, auto |
size |
provider default | auto, 1024x1024, 1024x1536, 1536x1024, 512, 1K, 2K, 4K |
background |
provider default | transparent, opaque, auto |
output_format |
provider default | png, webp, jpeg |
aspect_ratio |
provider default | 16:9, 1:1, 9:16, … |
Which providers can draw is the SDK's answer, not a list.
Model.supported_native_tools() is a classmethod on every model class Pydantic AI
ships, so the platform asks it: OpenAIResponsesModel, GoogleModel and
GoogleModel through Vertex honour ImageGenerationTool, nobody else does, and an
upgrade that teaches a fourth needs no code here. Together's and Fireworks' image
models are real and would fail on their first call with "not supported by this
model", which is why they are not offered.
Being able to draw and being configurable are two questions, and Vertex AI is where they part. The capability seals one API key and builds every provider with it, where Vertex wants a service account — so a Vertex entry would be a picker choice nobody can supply a credential for, and it is dropped alongside the undrawable ones. Offering it means teaching the capability provider-specific credential shapes, which is a capability change rather than a catalog one.
Which models each provider offers is data, in
app/core/catalog/image_models.json: an id, a name and a sentence saying when to
reach for it. No listing endpoint answers this question - /v1/models returns chat
models - so a model released this morning is one catalog entry rather than a
release. A provider entry the SDK cannot drive, or whose credential this
capability cannot build, is dropped when the file is read - the guard against the
file growing something undrawable or unconfigurable.
The two providers name the image model in different places, and the catalog carries
that too. For Google the chosen model is the image model. For OpenAI the
tool is called by a Responses model and draws with the chosen one, so the entry
names that caller and the choice travels as the tool's own model. Neither is a
question the author is asked.
A spec published before the pair existed still runs. This used to be one
enumerated string carrying the SDK prefix (openai-responses:gpt-5.4), and a
stored spec still holds it. Read against the two fields that is an unknown model,
so the config normalises the old shape on the way in: the prefix names the
provider, and a name that is the caller rather than a drawing model resolves to
that provider's first image model. Only where no provider was stored — a binding
that names one is stating both halves.
model also decides which provider the API key belongs to. The key is required —
publishing an agent that binds this without one is refused — and comes from the
organization's secrets, named by the binding's secret_id. Every
other setting is optional; unset, the provider applies its own default, so turning
the capability on is enough to generate.
It is side-effecting. Drawing an image spends real money on a provider key and produces content a person may publish, so every call is a candidate for the approval gate and can be gated per binding.
Its spend is metered. The image model is run as a subagent whose usage is
booked to the run's ledger, so image cost counts against a budget the same as a
model request. Image models are often unpriced by the pricing snapshot, in which
case the run records the call at zero and flags its total as partial (cost_is_partial)
rather than hiding the spend.
Where the image goes. Every generated image is stored per organization and
served back by GET /api/v1/generated/{filename}, scoped to
the caller's own organization — a wider boundary than a chat upload, which is owned
by one user, because there is no record of who produced an image. When the agent
also has a workspace (the sandbox capability), the same image is written into it
under /output, so a later execute step can build with it — assemble a PDF, a
slide, a page. An agent without a workspace still generates and shows images; it
simply has nowhere to build with them.
Delegation¶
task — hand a self-contained piece of work to one of this agent's specialists.
check_task, wait_tasks, list_active_tasks — following one that is running.
send_message_to_subagent, soft_cancel_task, hard_cancel_task — steering or
stopping one. These six are offered only when a background delegation is reachable
— a sync-only agent is handed none of them.
create_agent, delegate — a specialist the model writes for itself, when the author allows it.
answer_subagent — declared, and offered to no model.
One agent handing part of a job to another, each on its own model with its own
knowledge and its own step limit, addressed by name. There are two shapes of
delegate and the difference decides how it is reviewed, versioned and billed —
Concepts is where that is
explained. Which published agents this one may delegate to is not in this
config: it is subagents at the top level of the spec, where publish validation,
the YAML export and the permission model can all see it.
| Config | Default | Range |
|---|---|---|
inline |
none | specialists defined inside this agent |
mode |
sync |
sync, async, auto |
allow_questions |
false |
a sync delegate may ask the parent's person |
allow_dynamic |
false |
|
max_depth |
1 | 1–3 |
max_fanout |
3 | 1–10 |
max_result_chars |
2000 | 200–20000 |
share_with_delegates |
none | capability ids this agent is itself bound to, except subagents |
The mode is the author's decision, not the model's.
The library's task tool takes a mode argument defaulting to sync, so "the
model chose to wait" and "the model said nothing" are the same call. There is no way
to honour both a setting and a choice, and the setting was reviewed.
So the argument is replaced on the way through, and auto is how an author
deliberately hands the decision over. auto is resolved before the delegation
starts, because whether a panel stays open after the parent has answered depends on
the answer.
A pinned delegate or a specialist may override the mode for itself — one slow researcher is the case worth running in the background. The instructions then mark that delegate, beside its name: a single sentence stating the configured mode was a promise the overriding delegate then broke, telling the model to expect an answer and handing it a task id.
A sync-only agent is offered none of the six task-lifecycle tools. Each of
check_task, wait_tasks, list_active_tasks, send_message_to_subagent and the
two cancels takes or reports on a task id, and a sync delegation returns the
answer and nothing else — there is no id to pass. So they are offered only when a
background delegation is reachable: async or auto mode, a delegate that prefers
either, or permission to invent specialists. sync is the default, so this is the
common configuration, and six tool descriptions withheld is six the model no longer
pays for on every turn. task stays — a sync agent still delegates.
Fan-out and nesting are ceilings, not errors.
Past max_fanout the next delegation comes back as a tool result the model can act
on — wait, or do the work itself — because a pacing limit should not end a run.
max_depth counts levels of delegation including the configured agent's own: 1
is this agent delegating and its delegates not; 2 allows one nested level.
At the bound a delegate is built without the delegation capability, rather than with one that can only refuse. A tool that always answers "no delegates available" is a description the model pays for on every turn and tries anyway.
There is deliberately no 0. Turning delegation off is disabling the binding, and a second spelling of the same switch is one that disagrees with the first.
And every agent in the tree is held to its own max_depth, not the root's. A
delegate gets the lower of what the tree has left and what its own spec allows,
so a root configured for three levels delegating to an agent whose author chose 1
gets one: that delegate delegates and its delegates do not, exactly as its own
reviewers read it. A ceiling a caller could widen would not be one, and the reason
to pin a delegate to a version is that its author's decisions hold when somebody
else calls it.
A sync delegation can stop to ask a person, and is continued in place. A gated tool inside one parks the whole run; approving it resumes that delegate from where it stopped rather than delegating again, which is what makes the approval apply to the call the reviewer actually saw. Governance has the shape of the stored state and why re-running would answer differently.
A background delegation cannot stop to ask a person.
A gated tool inside one is refused rather than parked, and the refusal tells the
model to delegate that work with mode="sync" instead.
The reason is not policy but lifetime. The approval channel closes over the request's database session, and a background delegation outlives the tool call that started it — so by the time it wanted to ask, there is nothing left to write the question with.
A background delegation that suspends anyway — a shape the library documents as
undeliverable — is recorded failed with that same message. The alternative is a
task that reports "still running" for as long as the process lives: its spend
attributed to nothing, its fan-out slot never released, and the panel a surface
opened never closed.
A sync delegate may ask the parent's person, when allow_questions is set.
Off by default: a specialist works autonomously and says so if it could not.
Set on, a delegate whose mode is sync is given the library's ask_parent tool, and
a question it asks is answered by the run's own ask_user channel — the person
already holding the parent's tool call — never by the model.
It is the author's decision because the question wears a name the author published. A specialist the model invents never asks, whatever this says: instructions a model wrote a moment ago are not the author's to put to a person.
Only sync. A background delegation has handed back a task id with nobody left to
answer, and auto may become one.
Reaching a pre-built delegate needed an upstream change.
subagents-pydantic-ai#76
honours can_ask_questions for a caller-supplied agent, which every delegate here
is — landing the sync half of
#184.
answer_subagent is offered to no model.
It answers a question a background delegate parked on, and no delegate here parks
on one: a sync question goes to a person through ask_user and never this tool, and
an async delegate is not given ask_parent at all. So the tool's only possible
answer is "that delegation is not waiting for an answer".
It stays declared, because a tool absent from the declaration cannot be gated by the approval policy or renamed by a binding, and that half of the failure is silent.
It is filtered out of the offered set, because the other half is a description in every turn's context describing an action that cannot happen — and tool descriptions are the strongest prompt in this product.
The tool becomes reachable only when the background half of #184 is answered: where the parent's own model answers while nothing obliges it to look, the delegate blocking on a fan-out slot the turn's end cancels.
wait_tasks truncates, and says so. A completed task's result is cut at
max_result_chars with an explicit marker pointing at check_task, which always
returns the full text. The marker is the load-bearing half: a silent cut reads as a
short answer, and an orchestrator handed half a report re-delegates work it already
has.
Switching delegation off is disabling the binding, not lowering a number. A disabled binding is not delegation: nothing is built, so nothing reads the pins or the specialists it carries — and publishing is then refused for an agent that still names delegates, because a pin nothing will ever call is configuration that reads as a decision and does nothing.
Bound with no delegates at all, this capability contributes nothing — it is
not attached, the same way knowledge is not attached with no collections. Ten
tools that can only refuse are ten tools in every turn's context.
Only the three that act ask for approval: send_message_to_subagent,
soft_cancel_task and hard_cancel_task. Steering changes what a delegate is
doing mid-run, and either cancel destroys work that was paid for and not
delivered. task is deliberately not side-effecting, which reads wrong for a
moment: what a delegate does is gated by the delegate's own spec, through the
same approval gate this run uses, so gating the delegation as well would ask
somebody to approve it before the work that might need approving has been
proposed. An author who does want that has one tool_approval override.
A delegate is not lent the parent's capabilities. It runs on its own spec plus
whatever share_with_delegates names, one id at a time — a specialist that
silently gained the parent's credentials would be the quiet route around what the
parent was granted. Publishing refuses a shared id the parent is not itself bound
to, since lending what you do not hold is a line of configuration that reads as a
decision and does nothing. In practice this exists for sandbox: sharing it is
how a researcher writes /workspace/notes.md and a writer reads it. A delegate
that binds sandbox without being shared the parent's gets the in-memory
workspace, because only the run opens one.
subagents cannot be shared, and it is the one id "does the parent hold it"
could never refuse — an agent that shares anything holds it by definition.
Shared, the parent's binding lands on a delegate that binds none, and the runtime
then reads the parent's specialists, allow_dynamic, max_fanout, max_depth
and share list as though the delegate's author had chosen them.
Publishing refuses it, and the runtime drops it from the share list as well, so a spec stored before that rule cannot widen a delegate either.
Whether a delegate may delegate at all is its own spec's answer, and so is how deep it may go — bounded by what the tree above it has left.
Sharing is also the only route to an MCP connection for an inline specialist, which cannot bind one at all: a connection is organization-scoped configuration, and reaching one through a specialist nobody published is the wrong door. Bind it on the parent and name it here.
create_agent and delegate are offered only under allow_dynamic. A tool
absent from a capability's declaration cannot be gated by the approval policy or
renamed by a binding, and the dangerous half of that is silent — so all ten are
declared, and a default configuration offers seven.
What the switch buys is a specialist the model writes itself: instructions and a model, and nothing else.
It is built through the same build_agent an inline specialist comes through, on
the run's shared budget guard and its approval channel, so its requests are priced
and counted against the cap somebody set.
That is the entire reason this took a factory rather than a flag. A specialist the library built for itself would sit outside this deployment's model catalog, its vault and its budget guard — an unmetered request, possibly to a provider the organization holds no key for. The factory is what routes it back through this platform instead.
A specialist naming no model is refused
Before subagents-pydantic-ai 0.2.18 the library carried a default model string
a modelless specialist was compiled from. 0.2.18 removed that fallback, and this
platform refuses it earlier still, in DelegatingToolset._refuse_dynamic.
The model may name only a model the organization has a profile for, and the refusal
names the list. It may not attach capabilities: letting a model grant its own child
a capability is the ungranted-scope failure wearing a new hat. It gets no knowledge,
no delegates of its own, and nothing is persisted across runs — keeping a specialist
means publishing an agent, which is a person's action. MAX_DYNAMIC_SPECIALISTS
bounds how many one run may keep.
That a specialist is not persisted is a design, and it has an exit rather than a dead end: a person can promote one to a draft agent.
Its definition rides the opening SubagentStarted frame — the one place it is
legible after the model wrote it and before the turn ends — so the chat delegation
panel can offer to keep it while the run is still on screen, and the Builder offers
the same on an inline specialist.
Promotion creates a draft owned by whoever promoted it, gated on agents:edit, and
stops there: it does not publish, does not pin the new agent as a delegate, and does
not remove the specialist it came from.
See Concepts for why the persistence rule is the reason the exit exists, rather than a limitation it works around.
A kept one lasts the whole run it was invented in, an approval park included: the
registration lives in a registry the delegation library builds per built agent,
and a run that parks is built again when it is continued, so it was lost across the
park until the registrations were carried in PausedRunState and re-registered on
the replay (#175). It does not
survive into the next conversation turn, which is a fresh build with no paused
state — a name created in one reply is unknown in the next, and create_agent's
description tells the model to create it again if task says so.
The delegation library's own unspecialised delegate is not offered at all, and there is no setting for it.
Before subagents-pydantic-ai 0.2.18 it would have run on a model this deployment did not configure — compiled from the library's own default model string, outside the organization's profiles, its vault and the run's budget guard. Exactly like the run-time specialist above, before it took a factory.
A catch-all is a legitimate thing to want. Write it as an inline specialist, where you can read what it does and it is priced like everything else.
The library's own is fixed as of 0.2.18 (#174): with no default model or factory, it now refuses to build the delegate rather than picking a model.
What the model is told about all of this is written here rather than by the library: the delegates by name and description, the mode this run will actually use, and the fan-out ceiling it would otherwise discover by being refused. Two lists of the same delegates in one system prompt is context paid for twice, and only one of them can say what the deployment enforces.
For what a delegation costs and which run row records it, see Governance. For who may delegate to what, see Permissions.
Planning¶
write_plan — lay out or replace the whole checklist.
read_plan — see the steps and their ids before a granular edit.
add_task, update_task_status, update_task_statuses, remove_task — change one
step, or a batch, without replacing the plan.
add_subtask, set_dependency, get_available_tasks — dependency-aware planning,
offered only under enable_subtasks.
A checklist the model keeps for itself while it works: what is done, what is in progress, what is left. For multi-step work a model does better when it writes the steps down first and keeps them in front of itself, so the current plan is surfaced back every turn as a cache-safe tail reminder — appended after a cache breakpoint, so the stable prompt prefix stays byte-identical and only the mutable plan is re-read each turn. The plan never lands in the system prompt.
It overlaps with Delegation the way a plan overlaps with a team: planning decides what the steps are, delegation decides who does them. They are orthogonal — a plan is a toolset plus a reminder, delegation is a toolset plus a run wrapper — so an agent may bind both, one, or neither.
| Config | Default | Values |
|---|---|---|
enable_subtasks |
false |
adds the three subtask/dependency tools and the blocked status |
cache_ttl |
5m |
5m, 1h — how long the prefix before the reminder may cache |
None of the nine tools acts on the world. Each mutates a checklist the model
keeps for itself, so there is nothing here for a person to approve and the capability
declares side_effecting=False. The three subtask tools are declared even when a flat
checklist does not offer them, because a tool absent from the declaration can be
neither gated by the approval policy nor renamed by a binding.
The plan belongs to the conversation, not to one turn of it.
The checklist is state, and every boundary a run has would otherwise lose it. A run that parks on an approval mid-plan resumes as a fresh run — and a chat message is a run here, so the next message started from an empty store.
An agent wrote three steps, was asked to begin the first, and answered that no plan existed and it had never created one (agenticos#1077).
So the store is the runner's, not the capability's. It is seeded from the
conversation's stored plan, or from paused_state on a resume, which is the newer
copy, and written back to the conversation when the run stops.
A surface with no conversation — a bare API call — keeps a plan for the length of its run, which is all it has.
A finished checklist is history, and a new turn does not start from it. The
row keeps it — nothing is deleted — but a plan whose every step is completed or
cancelled is not seeded into the next turn: the tail reminder would call a task
nobody is doing "your current plan", and read_plan would answer with it
(agenticos#1221).
Keeping the row takes one more rule, because a turn writes its store back when it ends: the turn whose store was opened empty over a finished plan writes nothing. It did nothing to the checklist, and an empty dump would delete the row on the very next ordinary message. An agent that starts new work dumps a plan and replaces it as usual.
The filter is at the seed, not at the moment the last step is ticked, and that
is the whole of the choice. Within the turn that finishes a plan the store still
holds it, so the agent can summarise what it just did and nothing contradicts the
transcript. It is the next question that starts clean — and the ticked checklist
is still in the messages above it, where it reads as what was done rather than as
what is being done. A blocked step is work outstanding, so a plan holding one is
seeded: something still has to unblock it. A resume seeds from paused_state and
is untouched, being mid-plan by construction.
An agent that does not bind the capability pays nothing: no tools, no reminder, and nothing stored, because an empty checklist against a column that is null is not a change to write.
It spends no tokens of its own. The tools are local checklist edits with no model or embedding request behind them, so unlike knowledge or delegation there is no ambient usage to meter. The round trips the model makes to call them are its own, and the budget guard already counts those.
Thinking¶
No tools. Asks the model to reason before it answers: slower and dearer, better on work that needs several steps held in mind at once.
| Config | Default | Values |
|---|---|---|
effort |
unset | minimal, low, medium, high, xhigh |
Unset means the provider's own default effort. A level a provider does not have maps to its closest one, so a spec stays portable across a model swap.
System reminders¶
No tools. Re-states steering guidance mid-run so a long session stops drifting
from its instructions — the failure this fixes is instruction fade, where after
many tool-use turns a model progressively ignores the guidance it started with. It
is a port of
pydantic-ai-harness's
SystemReminders.
There are three reminder kinds, each with its own cadence:
| Kind | Cost | Text it injects |
|---|---|---|
reminders[] |
none | A fixed line you write |
goal_reanchor |
none | The run's first user request, re-stated as the anchor |
llm_reminder |
one model call per fire | A short nudge a model writes from the recent transcript |
Each kind takes interval (fire every N model requests), first_after (the
request number of the first fire) and max_fires (the cap over the conversation);
cache_ttl on the capability sets the lifetime of the cache breakpoint. At least
one kind must be set, or the capability contributes nothing and is dropped from the
run — which is what an empty config means.
The cadence counts across the whole conversation, and it is durable. A reminder fires on model-request number N, and that counter is stored on the conversation and seeded back at the next turn — so a reminder set to fire every ten requests keeps counting where the last turn left off rather than resetting to zero, and leaving and reloading a conversation resumes it (#787). Only the counters are stored; the reminder text is injected per request and never enters the transcript.
Injection is cache-safe. A fired reminder is appended to the tail of the
request as an ephemeral user prompt behind a cache breakpoint, after core has
persisted the durable history — so it reaches the model but never enters
message_history, no stale reminders pile up, and the cached prefix (tools, system,
the real conversation) stays byte-identical turn over turn while only the small
reminder falls outside the cache. Injecting into the system prompt instead would
bust the cached prefix on every fire and accumulate stale reminders.
An LLM reminder is metered and inherits the run's model.
It writes its text through an agent it builds itself, which no budget guard wraps — so its spend is booked against the run's ledger the way a summary is, and it runs under the run's usage limits minus one reserved request, so it can never push the run past its own step limit.
It uses the run's own model — the one whose credential the vault resolved — rather than a name from config, the same decision Context management makes about its summariser.
On any error, or when the reserved budget is already spent, it falls back to the goal-reanchor line. A failed generation never blocks the run.
Date and time¶
No tools. Puts the current date and time into the agent's instructions, so it stops assuming one — the failure this fixes is an agent confidently reasoning about "this quarter" from its training cutoff.
| Config | Default | |
|---|---|---|
timezone |
UTC |
any IANA name, e.g. Europe/Warsaw |
Context management¶
No tools. Trims a long run's message history before each request, so a run that
would have hit the model's limit keeps working instead. The strategies come from
pydantic-ai-harness.
| Config | Default | |
|---|---|---|
strategy |
summarize |
summarize, tiered, clear_tool_results, sliding_window |
max_fraction |
0.9 |
0.05–0.95 of the window, at which compaction starts |
keep_messages |
20 | recent messages that survive a summary or a window |
keep_tool_pairs |
3 | recent tool calls that keep their results |
summary_prompt |
the library's own | what the summarising model is told; must contain {messages} |
context_window |
unset | override the window — what this triggers against and what the chat's gauge divides by |
fallback_context_window |
200000 | window to assume when the model's cannot be resolved |
summarize is the default because it is the only strategy that keeps what the
older turns said. The zero-LLM ones are cheaper because they throw information
away — a sliding window drops the oldest messages outright, clearing a tool result
blanks an answer the agent may still need — and an agent that silently forgets
what it was told mid-run is a worse failure than a summary nobody asked for. It
fires at 0.9 of the window for the same reason: compaction is where a run starts
losing detail, so it is deferred until the window is nearly full.
tiered is the frugal choice and one binding away: it clears old tool results
first and pays for a summary only if that was not enough. Summarising turns input
tokens into output tokens, which are billed at a premium and generated serially,
so an agent whose runs are dominated by large tool results is usually better on
tiered.
It reaches one run, not one conversation. Between turns the history is rebuilt from the transcript as user and assistant text, so tool calls and their results are not there to compact and no edit made here survives a turn boundary. The history worth compacting is the long tool loop inside a single run, where one directory listing or knowledge search is tens of thousands of tokens.
The trigger is a fraction because an absolute number is only right for one model, and the same agent runs on whatever profile its spec points at. The window comes from the model profile, which recorded it from the provider's own listing when somebody added the model — see Which models a provider offers.
Where the profile recorded nothing, the window is resolved from the bundled price
snapshot instead, and two cases resolve wrongly — both in the direction that
breaks a run rather than the one that wastes a summary: a spec with fallbacks
builds a FallbackModel whose composite id resolves to nothing, and
genai-prices records 1,000,000 for anthropic:claude-sonnet-4-5 against a real
200,000, where max_fraction=0.9 puts the trigger at 900,000 and compaction never
fires. context_window overrides everything and is the answer to both — a
provider publishes the maximum a model can be made to accept, and a beta- or
tier-gated deployment gets less.
The trigger allows for what every request carries. It measures the message parts; a request also carries the instructions and every tool schema. On a real agent the estimator saw 60 tokens where the provider charged for 3,865 — so the overhead is measured against each response and the trigger's window is moved down by it, which is what keeps the gauge and the trigger describing one ceiling rather than two.
It waits for a response to measure from, so the first request of a run triggers on the messages alone. And it gives up when the overhead alone is past the trigger: no summary can get under it, the schemas are not in the history, and a corrected window would buy a summary on every request for ever.
When it gives up, it says so. A context_window smaller than the agent's own
overhead is that case, and doing nothing about it is indistinguishable on screen
from a setting that is working — so the chat shows what the overhead is and what
window it was measured against, which is the pair somebody needs to pick a number
that works. Once per run, because it describes a configuration rather than an
event, and it is displaced the moment a summary actually runs.
A summary says it is happening. It is a whole model request between two of the turn's own, where nothing else streams — the chat used to stop dead for the length of it, which reads as a broken screen and gets the page reloaded, cancelling the turn. The chat now shows what is being summarised while it happens. Only the summarising strategy: the others edit a list and return.
A summary is metered. The strategy writes it through an agent it builds itself, which no budget guard wraps, so the capability measures the run's usage across the hook and books the difference against the run's ledger. It is recorded rather than prevented: the guard refuses on the next request, so a compaction that crosses a cap stops the run after it, not during it.
The gauge beside it is not part of this binding. How full the window was is reported by every agent, whether or not it compacts — see how full the context window is. The warning matters most to the agent that will not compact, which is the one that reaches the ceiling and gets refused.
Media offload¶
No tools. Writes a compacted conversation's large parts out to storage and leaves
a media+sha256://… reference in the stored history. The content-addressed
stores and the walkers come from
pydantic-ai-harness.
| Config | Default | |
|---|---|---|
threshold_bytes |
32768 | 1 KiB–10 MiB; parts at least this large are stored out of line |
There is one place media actually piles up, and this is it. An attachment reaches the model once, on the turn it was attached: the ordinary history is rebuilt from the transcript's text, so a picture is not re-sent on later turns. A conversation that has been compacted is the exception — the library's own dump of the run's messages is stored whole and replayed exactly as the model last saw it, base64 and all, until the next summary replaces it. That blob is rows in Postgres and bytes on the wire, every turn in between.
Where the bytes go, and how long they live. Into the deployment's own file
storage, at media/<organization>/<conversation>/<digest>. The organization is
the isolation: a media URI is a content hash, so two tenants holding the same
picture compute the same URI, and the organization comes from the run rather than
from the URI. The conversation is the lifetime — a content hash records nothing
about who still references it, so the thread's prefix is removed with the thread
and the tenant's with the tenant. The store issues no public URL; a URL a model
provider can fetch is a URL anybody can.
Offloading is optional; restoring is not. Binding the capability is the decision to offload. Re-inlining happens for every conversation whether or not it is still bound, because a conversation whose agent was unbound afterwards still has markers in its history and a marker nobody re-inlines is a picture the model is handed in a language it does not read.
Both directions fail soft. Losing a summary a model was paid to produce, because a store hiccuped, is worse than a history that is larger than it needed to be. A failure is logged and the history is used as it stands.
It does not reduce what the model is sent: the parts are re-inlined before the request goes out, which is what keeps the run correct. Rewriting them to a URL the model fetches itself is a different feature and would need the public URL this store deliberately does not issue.
Tool output limits¶
One tool, read_tool_result. Where compaction trims history inside the window
between requests, this stops an oversized tool return from getting there in the
first place. A ToolReturnPart persists, so a grep over a large repository or a
verbose API response is re-sent in full on every later request of the run —
code_execution already clips at 8,000 characters for exactly this reason, which
is the right default and the wrong ceiling: the part that mattered is gone from the
model's view with nothing to act on. This reduces a return once, when it is
produced, and lets the reduced form persist. The reduction itself is
pydantic-ai-harness's
ToolOutputLimits.
| Config | Default | |
|---|---|---|
action |
spill |
spill, truncate, summarize |
threshold |
10000 | size at or above which a return is reduced |
over_tokens |
false |
measure the threshold in estimated tokens, not characters |
max_chars |
4000 | characters kept when a return is truncated, or a spill falls back to one |
truncation_strategy |
head_tail |
head, tail, head_tail — which end(s) to keep |
strip_ansi |
false |
strip terminal colour codes before measuring and reducing |
summary_prompt |
the library's own | what the summarising model is told; must contain {tool_name} and {output} |
spill is the default and the only lossless one: the full return is written to the
agent's backend and replaced with a handle, a preview and a shape sketch, and the
model reads slices of it on demand through read_tool_result(handle, offset, limit,
from_end, pattern) — the same page-through pattern read_file gives it over the
workspace. truncate is the cheap, lossy clamp with a marker saying what was cut;
summarize replaces the return with an LLM summary and is the expensive one.
A spill goes to the agent's own backend.
An agent that binds sandbox already has a filesystem — state, a Docker
container, Daytona — that the runner opened for the run and keyed to the
organization. The spill lives there, under a tool_output/ prefix, so it shares
that workspace's lifetime and the agent can even reach it through its own
read_file and grep.
On the default run session scope that lifetime is the run, which is what the
"must not outlive the run" requirement asks for.
A spill is a within-run artefact and never outlives the run on a longer-scoped
workspace (conversation, user, agent) either:
- a
stateworkspace has the reserved prefix stripped at flush, so accumulated spills can no longer push it toward its byte cap and refuse the agent's own writes; - a container workspace has the run's spills deleted off its filesystem when the workspace closes — by the exact handles the run recorded, never by sweeping the prefix, so two concurrent runs sharing a workspace cannot take each other's spills mid-flight (#803).
An agent with no backend gets an in-memory one built for the run and discarded with it, so the spill is never written to shared disk.
A spill the backend refuses — a state workspace already at its byte cap — falls
back to a truncation rather than a silent drop. So does a summarize whose model
call fails: summarize → spill → truncate.
A summary is billed to the run. Like compaction's, the summarising call runs
through an Agent the harness builds itself, outside the budget guard, so its
tokens are booked against the run's ledger through the same ambient-usage path — see
how a run's cost is counted. spill and truncate call no model
and cost nothing.
The harness composes reductions from an ordered list of size bands; here an author
picks one action at one threshold, because the Builder form cannot draw a nested
list — the same reason compaction picks a strategy rather than composing tiers.
Tool search¶
No tools of its own. Lets the agent find a tool from a large set instead of carrying every tool's schema in its context on every request. This matters most for MCP: an agent may bind an arbitrary number of servers, and every tool a server exposes is a schema the model pays for on each turn whether or not it ever calls it.
| Config | Default | Values |
|---|---|---|
strategy |
auto |
auto, keywords, bm25, regex |
max_results |
10 | 1–50, ignored by native search |
auto— native tool search where the provider offers it (Anthropic BM25 or regex, OpenAI server-side), the local keyword algorithm everywhere else.keywords— always match locally, on any provider.bm25/regex— force an Anthropic-native algorithm; a run on a provider with no native tool search errors rather than silently substituting another. The model is resolved separately from the spec, so this is a run-time cost the author accepts by naming one —autonever fails this way.
Enabling it is what defers the MCP toolsets. The capability and the deferral
are two halves of one decision: the library's ToolSearch is inert with nothing
deferred, and a deferred tool with no search to find it is a tool the model can
never call. So binding tool_search is what marks the connected servers'
toolsets for deferred loading — the registry's own tools stay visible, being few
and chosen per agent. An agent that does not bind it pays nothing and sees every
tool as before.
Deferral changes what the model sees, never a tool's identity. A discovered
MCP tool arrives under its real prefixed name, so the approval gate
still pairs on it and a binding's rename still reaches it; ToolSearch sits
outermost, reading the names a rename already applied.
It needs no metering. The two local strategies run in Python and spend no tokens; native search runs inside the provider's own request, whose usage the budget guard already meters; and the discovery round-trips are ordinary model requests the same guard wraps. The one shape that would escape it — a custom search callable that itself calls a model or an embedding — is deliberately not exposed.
Guardrails¶
No tools. Inspects the text flowing through a run at three edges and either
redacts a match or blocks the run. The checks are ready-made detectors from
pydantic-ai-harness; an agent is data, so the config selects and parameterises
them rather than carrying a Python guard.
| Edge | Reads | Redact | Block |
|---|---|---|---|
| input | the user's prompt | redact_secrets_in, redact_pii_in |
blocked_keywords_in |
| output | the agent's answer | redact_secrets_out, redact_pii_out |
blocked_keywords_out |
| tool result | what a tool returned, before the model reads it | redact_secrets_tool, redact_pii_tool |
blocked_keywords_tool |
| Config | Default | |
|---|---|---|
redact_secrets_* |
false |
scrub API keys, tokens, JWTs and PEM blocks |
redact_pii_* |
false |
scrub email, IBAN (mod-97), card (Luhn) and US SSN |
blocked_keywords_* |
"" |
comma- or newline-separated terms; a match ends the run |
Every field defaults off, and a capability enabled with no edge configured attaches nothing — an agent that does not use it pays nothing.
Redaction rewrites; a block is a run outcome. A redactor scrubs the match and
the run finishes — an answer that quoted a key back has still done the work. A
keyword block instead ends the run with status guardrail_blocked, its own outcome
beside budget_exceeded, because a refusal is the platform working and an operator
filtering for problems should be able to find it rather than have it read like any
completed answer. See Governance.
Tool-result screening is the reason this edge matters most. It is the only guard on untrusted content entering the loop — a fetched page, a file, an MCP server's response — where a prompt-injection payload would otherwise reach the model unread.
Two things are deliberately out of scope. Tool arguments are a structured
mapping with no text detector, so they are not an edge. And the harness's tool
approve verdict is not ported: approvals already park a run per
tool for a human decision, and a second, rule-driven path to the same mechanism is
what a single door avoids.
Chat channel lookup¶
get_channel_info — Describe the channel this conversation is happening in.
list_channel_members — List the people in this channel.
search_channels — Find other channels by name or purpose, without reading them.
read_channel_history — Read the most recent messages in this channel, newest last.
The one capability an agent's spec may not bind. It is granted per binding,
in the Builder under Where this agent is available, because an organization can
bind one agent to two Mattermost servers and three Slack workspaces — and "may it
read what was said in this channel" has a different answer on the internal one and
the customer one. A field on the spec would have one answer for all five, so
publish validation refuses channel_tools in a spec and the run assembles the
binding from the row that admitted the message, exactly as it appends that row's
prompt to the instructions.
It is still an ordinary registry capability, which is the point of doing it that
way rather than injecting a toolset: its tools can be gated by tool_approval and
renamed by tool_overrides, both of which read the spec.
| Config | Default | Range |
|---|---|---|
tools |
[] |
any of the four ids |
default_limit |
20 | 1–200 |
Nothing is granted by default, and what a platform cannot answer is not offered:
Telegram gives a bot no directory of chats to search and no way to read messages
it was not sent. docs/channels.md has the per-platform table and the reasoning.
Three properties hold on every platform:
- The bot's membership is the whole permission boundary. Every call uses the bot's own token, so the agent sees exactly what the bot sees.
- The model never names a channel. The tools are bound server-side to the one the message arrived in — in a thread, to the channel that holds it.
- Outside a channel it contributes nothing. A run from the dashboard, the API
or a schedule has no directory, so the capability is not attached at all — the
same reason
knowledgewith no collections is not.
What a binding may change¶
The catalog is the deployment's answer to "what exists". A spec's
capabilities[] entry is one agent's answer to "how do I use it", and it may
change four things:
| Field | Effect |
|---|---|
config |
Validated against that capability's schema at publish, not at run time |
approval |
default | required | never for every tool the capability contributes |
tool_approval |
The same, per tool, overriding approval |
tool_overrides |
The name and description the model sees, per tool |
secret_id |
Which organization secret satisfies a declared key requirement |
enabled |
Off without losing the configuration |
Approval is why a capability declares its tools at all. "May this agent write
files" and "may it read them" are two decisions even though one capability answers
both, so enabling stays per capability while approving happens per tool. default
follows the capability's own side_effecting flag.
A tool's description is the highest-leverage prompt in the product
It is what the model reads before deciding to call, and its name steers just
as hard: search_refund_policy is not search_documents. An agent that
needs different behaviour from the same tool usually needs these reworded,
not a second capability written.
Keyed on the tool's stable id, never on the name the model sees
That is what keeps an approval gate attached to a renamed tool. Keying it on the visible name would mean a rename silently removes the gate, and a side-effecting call then goes unattended with nothing reporting it. An id no such capability exposes
is refused at publish, and so is a name no model could call.
Scopes¶
A capability may declare scopes the organization must have granted, checked when the agent is assembled:
| Scope | Declared by |
|---|---|
knowledge:read |
knowledge, skills |
conversations:read |
conversation_search |
web:read |
web_research |
web:fetch |
web_fetch |
web:browse |
browser_choice, browser_use |
code:execute |
code_execution |
sandbox:execute |
sandbox |
agents:delegate |
subagents |
All eight are granted by default today
DEFAULT_GRANTED_SCOPES in app/services/agent_registry.py.
Per-organization scope management is roadmap work; the check
is live and honest in the meantime rather than disabled and forgotten.
agents:delegate is not the gate on who may be delegated to
That is agents:run, checked on the publisher against each delegate's row.
This scope answers a question no permission can: whether this deployment
allows agents to call agents at all. Remove it from that set and delegation
is off everywhere in one edit.
An operator who does not want nested runs or fan-out billing removes it, and
every spec that delegates then says so at publish rather than at 3am.
conversations:read is the same kind of lever for conversation search: one edit
stops every agent reading past conversations, for a deployment that considers a
transcript too sensitive to be searchable however narrowly the corpus is scoped.
What a tool tells the model¶
A tool definition is a prompt. The model picks a tool, and fills in its arguments, from nothing but the text attached to it — so every tool here carries four things, and the fourth is the one usually left out:
- What it does, in a sentence. This is also what the Builder shows beside the approval checkbox, so it is written once and read by both.
- When to use it, and when to use something else.
create_chartsays it is for numbers you already have andgenerate_imagefor something to be drawn;globsays it searches recursively wherelsdoes not. - What every argument means, including its default and its ceiling.
- What comes back — the shape of the answer, what a failure looks like, and
where the result is a truncated slice rather than the whole set. A model that
does not know
grepanswers in three different shapes depending onoutput_mode, or thatglobstops at 100 paths, reasons from a slice as though it were everything.
All four reach the model in one shape, and it is pydantic-ai's own: the prose inside
<summary>, the return description inside <returns>.
A tool written here gets that for free — the framework builds it from the
docstring's Returns: section.
A tool that comes from a library is registered with an explicit description, which
takes that path away. So its text goes through ToolText in
app/agents/capabilities/_tool_text.py, which renders what the framework would
have.
Two conventions in one tool list is one more thing for the model to reconcile, and
tests/test_tool_text_shape.py is what keeps there being one: it pins ToolText
against a tool pydantic-ai builds itself, and checks that every capability's tools
carry a return shape.
That covers the tools this deployment did not write, either: planning and the
delegation tools are handed this repository's text, web_fetch and
search_tools are re-described where they are built, and read_tool_result and
read_skill_resource are re-described in place on the library's own toolset.
One of those was worth the trouble beyond consistency — the library's sentence
for read_tool_result said nothing about what a handle answers with, which is
the one thing a model holding a handle needs.
A tool from a library that this repository has no text for keeps the library's,
which is the right default: run_skill_script is excluded rather than described,
and if it ever arrives it arrives saying whatever its author wrote.
A mistake, a result, and a refusal¶
How a tool reports trouble decides what the model does next, and the three are not interchangeable.
| The failure | What the tool does | Why |
|---|---|---|
The model's own arguments — a chart series with the wrong number of values, a context file that does not exist, a NameError in Python it wrote |
Retry prompt | Calling again differently is a plausible fix, and the message says what a correct call looks like |
| A transient failure of what is behind the tool — a search provider down, a knowledge base timing out | Retry prompt | An error in the shape of a result reads as "nothing found", and the model then answers from memory, confidently, without saying that it had to |
| A result that is simply bad news — a command that exited non-zero, a search with no hits, a channel this bot cannot see | Returned as text | It is the answer. The model reasons about it and moves on |
| A refusal — a permission rule, a capability the deployment does not offer | Returned as text | A retry prompt invites the model to look for a way around it |
Retries are budgeted: a tool call gets one attempt to correct itself, and a
retry raised past that budget ends the whole run rather than the call. So the
last attempt returns its message instead of raising — steered while there is
budget for it, and never worse than the answer it would have given anyway. The
one helper that decides this is app/agents/capabilities/_failures.py;
pydantic-ai-backend holds the same rule for the workspace tools.
Adding to this list¶
Capabilities are code — nothing an operator types brings a new one into being, which is what makes the set of things an agent can do reviewable. See Add a capability for a new one, or Add a tool to a capability when the capability already exists.
For tools nobody here has to write, see MCP.