Agent / Expose, do not reimplement

A small end-to-end example

Assume an ordinary Foldkit application has already been captured with Surface.application:

ts
const App = Surface.application({ Model, Message, initial, update })

A Surface can describe the read model an agent is allowed to see:

ts
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:

ts
type Principal = { readonly actorId: string }
const isOwner = (principal: Principal) => principal.actorId === 'owner'

Now specialize the agent API to this application:

ts
const AgentBuilder = Agent
  .forApplication(App)
  .withPrincipal<Principal>()

Then create one concrete contract:

ts
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 result

There is deliberately no exposeAll. A Message is unreachable unless the contract opts into it.

Finally, bind the static contract to the live application:

ts
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.