Skip to content

The agent spec

The most load-bearing type in the platform: the Builder edits it, the database versions it, the factory instantiates it, and clients export it to their own git repositories as YAML.

Generated from the source, because the reasoning lives in the docstrings.

app.agents.spec.AgentSpec

Bases: BaseModel

Everything that defines an agent's behaviour.

Deliberately excluded: anything about where the agent runs (surfaces, channels) and anything about who may use it (owner, sharing). Those are deployment and access facts; keeping them out means the same spec can be exported, reviewed and reused across organizations.

bindings

bindings()

The spec's capabilities as the registry consumes them.

to_yaml

to_yaml()

Render the spec for a client's git repository.

Keys keep spec order rather than being sorted, so a diff reflects what changed rather than where it happens to sort. UUIDs become strings so the file round-trips through any YAML reader.

from_yaml classmethod

from_yaml(text)

Parse a spec written or edited by hand.

Raises:

Type Description
ValueError

If the document is not a mapping. Pydantic reports field problems itself, but a list or a bare string reaches it as an unhelpful type error.

Delegation

Two shapes, and Concepts explains which to reach for. subagents holds the first; the second lives in the subagents capability's own config.

app.agents.spec.SubagentRef

Bases: BaseModel

One published agent this agent may delegate to, pinned to a version.

Two ids rather than one, and the second is the whole point. A delegate is a real agent - versioned, permission-checked at publish, with its own capabilities, its own model and its own collections - and a reference that named only the agent would let its behaviour change under a published parent with nothing recording that anything had changed. Pinning means a fix to a delegate reaches its callers when somebody says so, which is the same guarantee publishing gives everywhere else in this product.

The cost of pinning is real and is paid in the Builder: a parent whose delegate has moved on is stale, and staleness that nothing surfaces is a bug frozen in place forever. The draft compares each pin against the delegate's current version and offers to move it.

A pin whose version no longer exists fails the run, loudly, naming the delegate. Never a quiet fall back to the current version: the reason to pin is that nothing changes without a decision, and a silent upgrade is worse than a refusal because nobody finds out.

app.agents.spec.SpecialistSpec

Bases: BaseModel

A specialist defined inside another agent, rather than published.

Worth having, and worth being honest about. A "summarise this in three bullets" specialist should not require somebody to publish an agent, and this is that: a name, a description the parent's model reads before delegating, instructions, and - because a summariser that cannot read the collection is useless - its own capabilities, collections and skills.

Which makes it an agent in every way except one, and the exception is the important one: a specialist is not versioned. It has no version row, it cannot be pinned, nothing else can reference it, and editing the parent changes it. That is the difference between this and :class:SubagentRef, and it is why the Builder must present them as two different things rather than two tabs of one.

The risk this shape exists to contain is a second, parallel notion of "agent" - one that publish validation does not walk and the permission model cannot see. It is contained by refusing to write a second format: this is a typed subset of :class:AgentSpec, using the same :class:CapabilityBindingSpec, validated by the same recursive pass in validate_spec, and assembled by the same build_agent. One spec type, one validator, one builder, one Builder component, each used recursively. If any of those five grows a second copy for specialists, the copy is the bug.

Deliberately absent, each because it only means something for a thing with a version or an owner:

  • budget - inside a delegation the parent's caps bind. Two budget guards metering one shared ledger would double-count every request.
  • notifications and observability - a specialist is not the subject of an alert or a Logfire service; the run it happens inside is.
  • mcp_server_ids - an MCP connection is organization-scoped configuration, and reaching one through a specialist nobody published is the wrong door. There is deliberately no route to one from here: share_with_delegates lends capability bindings, and an MCP connection is not a capability, so naming one there would configure nothing. A specialist that needs an external tool is a specialist that should be a published agent.
  • subagents - a specialist does not delegate further. Nesting is what max_depth bounds, and it is bounded for published delegates, which are reviewable.

bindings

bindings()

The specialist's capabilities as the registry consumes them.

to_agent_spec

to_agent_spec(*, fallback_model_profile_id)

This specialist as the spec the factory already knows how to build.

The one method that keeps "one spec type, one validator, one builder" true rather than aspirational. A specialist is a subset of an agent, so the way to build one is to say which agent it is and hand it to build_agent - not to write a second assembly path that will drift from the first the moment a field is added to either.

fallback_model_profile_id is the parent's, used when the specialist names none: a specialist with no model of its own runs on the model of the agent that called it, which is both the least surprising answer and the only one that works when the parent is the only agent whose profile the author chose.

The fields this drops are the ones :class:SpecialistSpec deliberately does not have - budget, notifications, observability, MCP connections, subagents - so they arrive at their AgentSpec defaults: no cap of its own (the run's caps bind), no alerts of its own, no Logfire project of its own, no connections, and no delegating further.

Budgets

app.agents.spec.BudgetSpec

Bases: BaseModel

Spending limit for this agent.

One monthly cap, metered against this agent's own runs. The platform has exactly two budget levels - the agent's and the organization's - and this is the agent's half.

Alerts

app.agents.spec.NotificationSpec

Bases: BaseModel

Which of this agent's alerts are sent, and who hears each one.

Per agent because the alerts are about an agent. A deployment-wide switch made the noisy agent and the one nobody may miss the same setting, so the only way to quieten the first was to go deaf to the second.

What is not here: the organization's own monthly cap. That limit is not this agent's to describe - it stops every agent in the organization, its ceiling is set in the organization's settings, and an agent's author cannot raise it. Its alert goes to the organization's admins and is not configurable from a spec.

app.agents.spec.AlertSpec

Bases: BaseModel

Whether one kind of alert is sent for this agent, and to whom.

Every audience is resolved and the addresses are merged, so naming the same person twice mails them once. Each recipient's own /settings/notifications switch is applied last and can only ever remove them: an agent cannot conscript somebody into an inbox they opted out of.

app.agents.spec.AlertAudience

Bases: StrEnum

Who an agent's alert reaches.

A list of these rather than one value, because the real answers are unions: "the person who asked, and the admins" is the sensible default for an approval and cannot be said with a single choice.

Deliberately roles rather than addresses, with CHOSEN as the one escape hatch. An audience of user ids goes stale the moment somebody leaves, and a spec is exported to a client's repository - ADMINS still means the right people after a reorganisation, and it means them in whichever organization the spec is imported into.

ADMINS class-attribute instance-attribute

ADMINS = 'admins'

The organization's owners and admins, plus the deployment's app admins.

OWNER class-attribute instance-attribute

OWNER = 'owner'

Whoever owns the agent - the person who would fix its configuration.

INITIATOR class-attribute instance-attribute

INITIATOR = 'initiator'

Whoever started the run. Nobody, for a run no person began.

CHOSEN class-attribute instance-attribute

CHOSEN = 'chosen'

Exactly the members named in user_ids.

Model settings

app.agents.spec.ModelSettingsSpec

Bases: BaseModel

How this agent asks its model to behave.

A deliberately small window onto Pydantic AI's ModelSettings. The full set includes escape hatches for someone debugging a provider - raw bodies, raw headers, token biases - which in a Builder are an invitation to paste something that breaks a published agent, and knobs that only some providers implement, which quietly mean something else after a model swap. _MODEL_SETTINGS_WITHDRAWN says why each excluded key went. What is left is what an agent author reaches for: how varied the answer is, how long it may be, how long it may take, and whether tools may run at once.

Reasoning is not here. It is the thinking capability, and a second control writing the same provider parameter would disagree with it silently.

Every field is optional and unset means unset, which is the one property the rest of this model is arranged around. None is not "send the provider's default" - it is "do not send this parameter", and the difference is a run that fails: reasoning models reject temperature outright, so an agent that never chose one must produce a request with no temperature key at all. Hence the serializer: an unset field is absent from the stored spec rather than stored as null, so nothing downstream - the merge in app/agents/factory.py, a YAML export, the Builder deciding whether to show a field as touched - has to know that a null here means "no".

Observability

app.agents.spec.ObservabilitySpec

Bases: BaseModel

Where this agent's traces go.

Per agent rather than per deployment because the interesting case is a client's agent whose traces belong in the client's own Logfire project, not in the operator's. The deployment-wide configuration stays as it is and keeps receiving everything else; this only redirects the runs of the agent that asks for it.

The token is a reference, never a value - like every other credential a spec names. A spec is exported as YAML into somebody's repository, and a write token in a checked-in file is a token that has to be rotated.

organization and project are the other half of that redirection, and they are here rather than in deployment settings for the same reason the token is: a token is a write credential and names neither, so a deployment knows where it sends an agent's traces and not where to read them. Both are slugs a client tells us; nothing can derive them. With neither set the run's trace id is still recorded and no link is offered - which is a configuration fact rather than a promise the schema is failing to keep (#206).