Agent / Expose, do not reimplement
A small end-to-end example
Assume an ordinary Foldkit application has already been captured with
Surface.application:
const App = Surface.application({ Model, Message, initial, update })A Surface can describe the read model an agent is allowed to see:
const Overview = App.surface('Overview', {
model: ({ model }) => ({
listTitle: model.listTitle,
todos: model.todos,
filter: model.filter,
}),
})Notice what is missing. If the root Model also contains editor state, a pending form error, or internal bookkeeping, none of it becomes agent context merely because it exists.
For the examples below, assume the application identifies callers like this:
type Principal = { readonly actorId: string }
const isOwner = (principal: Principal) => principal.actorId === 'owner'Now specialize the agent API to this application:
const AgentBuilder = Agent
.forApplication(App)
.withPrincipal<Principal>()Then create one concrete contract:
const AssistantAgent = AgentBuilder.make({
name: 'assistant',
context: Overview,
messages: AgentBuilder.expose(Message, {
RequestedTodo: Agent.variant({
name: 'add_todo',
description: 'Add a todo with the given title',
input: Schema.Struct({ title: Schema.String }),
toMessage: ({ title }) => ({ title }),
completion: {
success: Message.SubmittedTodo,
correlate: (request, result) =>
request.title.trim() === result.title,
},
}),
ToggledTodo: {
name: 'toggle_todo',
description: 'Mark a todo done, or undo that',
},
ClearedCompleted: {
name: 'clear_completed',
description: 'Delete every completed todo (owner only)',
authorize: ({ principal }) => isOwner(principal),
},
}),
})Read this literally:
context: Overview
-> this is what the agent may observe
RequestedTodo / ToggledTodo / ClearedCompleted
-> these are the only application Messages it may cause
input / toMessage
-> this is the protocol-facing input boundary
authorize
-> this is policy for one capability
completion
-> this is how dispatch becomes a meaningful operation resultThere is deliberately no exposeAll. A Message is unreachable unless the
contract opts into it.
Finally, bind the static contract to the live application:
const agentRuntime = AgentBuilder.bind({
definition: AssistantAgent,
host: {
model: mounted.model,
dispatch: message => {
mounted.dispatch(message)
},
subscribe: mounted.subscribe,
observe: mounted.observe,
principal: () => principal,
},
})The contract is static application architecture. The host supplies runtime facts: the current Model, how to dispatch, how to follow Model changes, how to observe Messages for completion, and who the current principal is.
A host only needs the capabilities the contract actually uses. For example,
observe matters when a capability declares completion, while subscribe
lets adapters such as WebMCP follow capabilities whose availability changes with
the Model.