Agent / foldkit-agent
Usage
Start from a normal Foldkit application:
import { Schema } from 'effect'
import { defineMessageUnion } from 'foldkit/message'
const Todo = Schema.Struct({
id: Schema.String,
title: Schema.String,
completed: Schema.Boolean,
})
const Model = Schema.Struct({
todos: Schema.Array(Todo),
selectedTodoId: Schema.Option(Schema.String),
})
type Model = typeof Model.Type
const Message = defineMessageUnion({
RequestedCreateTodo: { title: Schema.String },
RequestedRenameTodo: { id: Schema.String, title: Schema.String },
RequestedDeleteTodo: { id: Schema.String },
ReceivedTodos: { todos: Schema.Array(Todo) },
FailedToLoadTodos: { message: Schema.String },
})Bind the constructors to your application, then declare the contract:
import { Agent } from 'foldkit-agent'
import { Projection, Surface } from 'foldkit-surface'
import { Effect, Option } from 'effect'
const App = Surface.application({ Model, Message })
const TodoAgent = Agent.forApplication(App)
const AppAgent = TodoAgent.make({
// This projection is the context returned to the agent.
context: Projection.pick(App.model.todos, App.model.selectedTodoId),
messages: TodoAgent.expose(Message, {
// A variant that needs nothing but a description can be written as one.
RequestedCreateTodo: 'Create a new todo',
RequestedRenameTodo: 'Rename an existing todo',
RequestedDeleteTodo: {
name: 'delete_todo',
description: 'Delete the selected todo',
available: model => Option.isSome(model.selectedTodoId),
},
}),
})ReceivedTodos and FailedToLoadTodos are never reachable by an agent.
Exposure is opt-in, and there is deliberately no exposeAll().
The contract is a declaration: it does not run update or register tools.
Bind it to your existing runtime using three host functions: currentModel
reads the current immutable Model, sendToRuntime dispatches a Message, and
onModelChange subscribes a callback and returns an unsubscribe function.
The todo host implements that seam.
const agentRuntime = TodoAgent.bind({
definition: AppAgent,
host: {
model: currentModel,
dispatch: sendToRuntime,
subscribe: onModelChange,
},
})Once bound, an in-process invocation uses the same path as a protocol adapter:
const result = await Effect.runPromise(
agentRuntime.messages.dispatch(Message.RequestedCreateTodo, { title: 'Read the guide' }),
)dispatch returns an Effect; constructing it performs no dispatch. Running it
decodes the input, checks policy, and sends the Message. This example has no
completion contract, so success means dispatch finished, not that a remote
save completed. Add completion when the application's later result matters.
The host must accept every Message the contract can construct -- a wider union
is fine, a narrower one is a type error -- and a contract whose hooks read a
principal must be given a principal provider of the matching type.
Wiring
The contract joins a foldkit-bundle assembly contract-only, so the Module
sees it:
const wiring = AppAgent.wiring()It routes nothing and subscribes to nothing -- an agent adds no state -- and
wiring.contract is the same contract above.
Choosing the completion boundary
| Capability does | Meaning of a successful call | Required host seam |
|---|---|---|
| Immediate transition, no completion declared | Validated dispatch finished | model, dispatch |
| Work that later emits a result Message | A matching result was observed | Add observe; correlate overlapping calls |
| Work whose result is a condition on state | The declared condition holds | Add subscribe (or a subscribable source) |
A timeout or cancellation after dispatch does not roll back the application. Read current state before deciding whether to retry an effectful operation.