Agent / foldkit-agent
API
| Function | Purpose |
|---|---|
Surface.application({ Model, Message, initial, update }) | Captures the application once; App.model are typed field references. initial/update are optional (a runnable application is needed only for sync). |
Projection.pick(App.model.todos, ...) | The information boundary: what an agent may see. |
Projection.compose(...) | Compose disjoint picks into one context. |
MessageSet.make(App, [constructors]) / MessageSet.union(...) | A typed Message subset, and the union of several disjoint subsets. |
Agent.expose(Message, variants) | The capability boundary: what an agent may do. |
Agent.exposeSubset(subset, variants) | The same, restricted to a MessageSet.make subset. |
Agent.variant(config) | A mapped variant whose callbacks are inferred from its input. |
Agent.action(action, { available?, authorize? }) | A foldkit-surface Action as a variant: its name, description, input and Message. |
Agent.resource(name, options) | A named read-only projection of Model state. |
Agent.make({ context, messages, resources }) | The protocol-neutral contract; context is a Projection, a Projection.pick, or a feature Surface. |
Agent.forApplication(App) / Agent.forApplication(App).withPrincipal<Principal>() | The above, with Model inferred from a Surface.application; withPrincipal fixes the Principal type, or takes it from the Context key a server reads it from (withPrincipal(G.Principal)), so the two are one type. |
Agent.forModel<Model>() | The same, when only a Model (no application) is available. |
Agent.bind({ definition, host }) | Binds the contract to a live Runtime. |
Agent.schema/messages/resources/contextSchema | Introspection, as plain data. |
Agent.toManifest(definition) | The contract as agent.json, for committing and diffing. |
Agent.toMarkdown(definition) | The contract as documentation. |
Agent.auditLog(options) | A bounded record of decisions, refusals included. |
Agent.summarize(result) | One outcome summary, so the adapters map results to their protocols the same way. |
Agent.newInvocationId() | The default invocation id generator, for an adapter supplying its own. |
Variant configuration
RequestedDeleteTodo: {
name: 'delete_todo', // optional; defaults to requested_delete_todo
description: 'Delete a todo', // required
available: model => ..., // optional: does the capability exist in this Model?
authorize: ({ principal, input, model, transport }) => ..., // optional
input: Schema.Struct({ id: Schema.String }), // optional external input
toMessage: ({ id }, { invocation }) => ({ id, source: 'Agent' }),
}input and toMessage must be supplied together; supplying input alone is a
type error and a runtime error.
Written inline, toMessage and authorize are both checked against the
variant's own input -- the payload for a direct variant, the input codec's
decoded type for a mapped one -- alongside principal, model and transport.
No annotation is needed.
authorize answers true, false, or { allowed: false, reason }, or an
Effect of one. A refusal with a reason is the AgentAuthorizationError's
message, so the agent learns why; a bare false says only that it was not
authorized.
The one thing expose cannot infer inline is completion. success and
failure are inference sites, so written inline correlate receives any
Message of the union; Agent.variant narrows it to the Messages the contract
actually names:
RequestedDeleteTodo: Agent.variant({
description: 'Delete a todo',
input: Schema.Struct({ id: Schema.String }),
toMessage: ({ id }, { invocation }) => ({ id, requestId: invocation.id }),
completion: {
success: Message.DeletedTodo,
// `result` is DeletedTodo here, not any Message.
correlate: (request, result) => request.id === result.id,
},
}),Reach for it when a completion contract's correlate should be checked against
the Messages it names. Otherwise write the variant inline.
A capability already declared as a foldkit-surface Action, because a page
Block's button causes it too, is exposed with Agent.action under the tag of
the Message it makes; exposing it under another tag is a type error, and a
dispatch through it refused:
Agent.expose(Message, {
AddedToCart: Agent.action(AddToCart, {
// Give the principal its type: an Action's extras are not tied to an application.
authorize: ({ principal }: { readonly principal: Shopper }) => principal.canBuy,
}),
})Without an explicit name, the tag is normalized: RequestedDeleteTodo becomes
requested_delete_todo. Every capital starts a word, with no special case for
runs of them, so a tag with an acronym is better given an explicit name.
A capability name becomes a protocol-facing tool name, so it must match
[a-zA-Z0-9_-]{1,128}. An invalid name fails at Agent.expose, rather than at
registration where the client would reject it with no reference back to the
contract.
Introspection
The contract is data, so it is testable without an LLM:
expect(Agent.messages(AppAgent).map(message => message.name)).toEqual([
'create_todo',
'rename_todo',
'delete_todo',
])Documenting a contract
The contract is data, so its documentation is generated rather than written:
writeFileSync('agent.json', JSON.stringify(Agent.toManifest(AppAgent), null, 2))
writeFileSync('AGENT.md', Agent.toMarkdown(AppAgent))Both are reproducible for a given contract and read nothing from the Model, so a committed manifest makes a change to what an application exposes show up in review rather than only at runtime.
Dispatch
Every invocation runs the same five steps, in this order:
- Resolve the capability, by Message constructor or by protocol name.
available(model)— does this capability exist in the current Model?- Decode the input through the capability's own Effect Schema.
authorize— may this principal do it?- Construct and dispatch the Message into the host.
A refusal at any step before the last sends no Message at all. available runs
before authorize deliberately: a capability the Model does not offer reports
as unavailable rather than leaking whether the caller would have been permitted.
All of steps 2 to 4 read one Model snapshot, taken when dispatch begins.
A capability can be named by its Message constructor or by its protocol name. Both are checked, and both infer the input:
agentRuntime.messages.dispatch(Message.RequestedDeleteTodo, { id: 'todo-1' })
agentRuntime.messages.dispatch('delete_todo', { id: 'todo-1' })
// Errors, at compile time:
agentRuntime.messages.dispatch(Message.RequestedDeleteTodo, { todoId: 'x' }) // wrong payload
agentRuntime.messages.dispatch(Message.ReceivedTodos, { todos: [] }) // not exposed
agentRuntime.messages.dispatch('delete_todoo', { id: 'todo-1' }) // no such capabilityThe reference form is worth preferring: it survives a rename of the capability, and it needs no name at all for a variant that never declared one.
invocation is optional. In-app callers can omit it -- the id is generated and
the transport defaults to in-app -- while an adapter passes its own:
agentRuntime.messages.dispatch(Message.RequestedDeleteTodo, { id: 'todo-1' }, {
id: crypto.randomUUID(),
transport: 'webmcp',
signal,
})An adapter reads a tool name and an unvalidated payload off the wire, so
neither can be checked at compile time. That path is dispatchUnknown:
agentRuntime.messages.dispatchUnknown(nameFromTheWire, payloadFromTheWire, invocation)Input is typed as the encoded side of the capability's schema, because that
is what dispatch decodes. A payload of Schema.NumberFromString is sent as a
string and reaches update as a number.
Audit
Every decision the contract makes can be recorded, refusals included:
const audit = Agent.auditLog({
capacity: 500,
principal: (caller: User) => caller.id, // an id, not the whole identity
})
const agentRuntime = TodoAgent.bind({ definition: AppAgent, host, audit })
audit.entries()
// [{ at, invocation, transport, capability, tag, principal,
// decision: 'refused', outcome: 'AgentAuthorizationError' }]What it will not keep:
- The input, unless you ask. It is caller-supplied and may carry anything.
includeInput: trueturns it on, andredact: ['token']replaces named fields. - The principal, without a projection. No projection, no principal -- identities carry secrets.
- The Model. Never, by any setting.
The log is a bounded ring buffer, and a sink that throws never fails a dispatch:
accountability must not become a new way for a capability to break. Pass any
AuditSink to forward entries somewhere durable instead.
This is not Model replay. Re-dispatching a recorded invocation stays an explicit human action.
Completion
Dispatching a Message and completing an operation are not always the same event. A capability can say which Messages finish its work:
RequestedDeleteTodo: {
description: 'Delete a todo',
completion: {
success: Message.DeletedTodo,
failure: Message.FailedDeleteTodo,
correlate: (request, result) => request.id === result.id,
timeout: Duration.seconds(10),
},
}dispatch then waits, and the result carries
completion: { status: 'completed' | 'failed', message }. Without a contract,
validated dispatch remains the boundary and the field is absent.
This needs a host that can see Messages, not only receive them:
host: {
model: currentModel,
dispatch: sendToRuntime,
observe: onMessage, // required by a contract that declares completion
}A contract declaring completion is refused at bind when the host cannot
observe, rather than silently reporting every call as complete.
Waiting always has a deadline; it defaults to 30 seconds. It runs from the
moment the host's dispatch is called, so a host that never returns is bounded
by the same budget. AgentCompletionTimeoutError means the wait ended, not
that anything was undone -- the Message reached update. The same is true of
cancelling while a completion is pending.
An abort also settles a host dispatch that has not returned, with or without a
completion contract; without one there is no deadline, so only the signal
bounds it. Either way the error says delivery is unknown, AgentCancelledError
carries dispatched: false, and the audit records decision: 'unknown'.
Give correlate whenever two invocations of a capability can be in flight at
once. Without it the first matching Message wins, whichever invocation caused it.
Its third argument is the call, { invocation }. Where the input cannot tell
two calls apart (two todos titled "Milk"), pass invocation.id into the Message
from toMessage, carry it onto the fact the Command produces, and correlate on
it:
toMessage: ({ title }, { invocation }) => ({ title, requestId: invocation.id }),
completion: {
success: Message.SubmittedTodo,
correlate: (_, result, { invocation }) => result.requestId === invocation.id,
},Completing on state
A Message names one implementation path. When what matters is the outcome -- the project now has the requested name, whether a Command result, a live update, a Sync exchange, or another device put it there -- complete on state instead:
RequestedRenameProject: {
description: 'Rename a project',
completion: Agent.when({
projection: ProjectName,
predicate: (name, request) => name === request.name,
}),
}predicate reads the projection's value and the capability's input. Both are
inferred when when is written inline in Agent.expose; inside Agent.variant
annotate request, which is checked against the variant's input. The projection must read
this application's Model, and the host needs subscribe; bind refuses one
without it. A value outside the Model reads through source: { get, subscribe }
instead, such as foldkit-sync's mounted.committed.
Completed means the condition holds, not that this call made it true. Dispatch
subscribes before sending and checks once more when it starts waiting, so a
change update makes synchronously is not missed, and a state that already
holds completes at once. Write a predicate only this call can make true; a
creation, where only the resulting fact says which record is this call's,
completes on its Message with correlate.
The result carries status: 'completed' and no message. A predicate that
throws fails that invocation as a defect, never the code that changed the Model,
and a projected value a notification left unchanged is not evaluated again (a
source is re-evaluated on every notification, since it may mutate in place). timeout and
cancellation behave as they do for a Message contract.
An invocation whose signal is already aborted is refused before the Model is
read, and one aborted while decoding or authorize is pending never constructs
or dispatches its Message. Both fail with AgentCancelledError.
A host failure may occur after delivery, and completion timeouts and
cancellation after delivery do not undo it: the Message reached update.
Checking available before authorize is one half of a single guarantee: an
unavailable capability is
absent from messages.available, and dispatching it by name fails with
AgentCapabilityUnavailableError. Availability is stronger than tool
visibility -- knowing the name is not enough. Splitting it into separate
discoverability and invocability predicates was considered and rejected:
whichever predicate gates dispatch still has to run before authorize, so a
split reintroduces the ordering hazard this order exists to prevent. A
genuinely "hidden but invocable" capability would be a second predicate layered
on available, not a replacement for it, and nothing here needs one yet.
Decoding rejects undeclared fields, matching the additionalProperties: false
the derived JSON Schema advertises. A capability
with no payload accepts {} and nothing else -- not [], not a string, and not
an object with fields it never declared.
Failures are tagged and Schema-backed, so Effect.catchTag narrows them and
an adapter can encode one to JSON and send it across a protocol boundary:
dispatch.pipe(
Effect.catchTag('AgentCapabilityUnavailableError', error =>
Effect.succeed(`${error.capability} is not available`),
),
)AgentUnknownCapabilityError, AgentCapabilityUnavailableError,
AgentInvalidInputError, AgentAuthorizationError, AgentCancelledError, and
AgentResourceError
for resource reads. Every message is written for the calling agent: it names
the capability and never restates the underlying decode failure, which stays on
the error's cause for the application.
Dispatch runs inside an Agent.dispatch span annotated with the capability,
transport, and invocation id, so agent-originated transitions show up in
tracing alongside the rest of the application.
The Model snapshot
An invocation captures one Model snapshot when dispatch begins. available,
authorize, and toMessage all observe that snapshot, and a later Model change
does not retarget an invocation already in flight.
host.model() is called once per invocation, not once per predicate, and
InvocationContext.model is that snapshot -- the same value available was
checked against. There is no separate field for it.
The host must treat returned Models as immutable and replace them on updates. The runtime retains the reference; it does not clone the Model. Unknown capabilities and already-cancelled invocations never read the Model.
This matters because a contextual capability can read the Model twice:
RequestedDeleteTodo: {
name: 'delete_selected_todo',
description: 'Delete the currently selected todo',
available: model => Option.isSome(model.selectedTodoId),
input: Schema.Struct({}),
toMessage: (_, { model }) => ({ id: Option.getOrThrow(model.selectedTodoId) }),
}That is examples/todo as written.
The getOrThrow is safe only because the Model toMessage reads is the one
available approved. Re-reading after an async authorize would let the two
disagree: available passing against a new Model while toMessage built from
the old one, or the reverse, so a capability could be dispatched with the
selection it was never offered for -- or throw on a selection that had just been
cleared.
Snapshot consistency is not live-state freshness. One invocation sees one Model; a later invocation sees the newer one. The Runtime guarantees the first, not the second. If the user changes the selection while an invocation is suspended, that invocation still targets the selection captured when dispatch began.
Nothing rejects a dispatch because the live Model has advanced since capture. Optimistic concurrency -- a version on the snapshot, and an adapter opting into failing the stale invocation -- is deliberately not provided, on the same footing as the second availability predicate above: it can be added when a real case needs it, and adding it early would fix semantics nothing has asked for.