Agent / foldkit-agent

Usage

Start from a normal Foldkit application:

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

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

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

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

ts
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 doesMeaning of a successful callRequired host seam
Immediate transition, no completion declaredValidated dispatch finishedmodel, dispatch
Work that later emits a result MessageA matching result was observedAdd observe; correlate overlapping calls
Work whose result is a condition on stateThe declared condition holdsAdd 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.