Packages
For developers. Start with the developer manual to choose API work, App publishing, or project changes.
OpenBot is a pnpm workspace. Each package has one job. openbot puts the parts
together into the server. You can use the other libraries on their own.
All packages are named @openbot-build/<name>, have version 0.1.0, and use
effect@4.0.5. They are not on npm yet. Use them from the workspace with
"workspace:*".
Overview
Section titled “Overview”| Package | What it is for |
|---|---|
openbot |
The server, its plugins, and its model providers. See Run from code. |
api |
The typed HTTP contract. The server, the client, and the API reference use it. |
client |
The TypeScript client. See Client. |
core |
The Bot core: Bots, Chats, Turns, storage, plugins, and skills. |
protocol |
The OpenBot App Protocol: publish, find, connect, and index Apps. |
protocol-test |
A public test service for the App Protocol. See Protocol test. |
cli |
The openbot command. It runs openbot-<name> for openbot <name>. |
computer |
The openbot computer command, which pairs and connects your own Computer. |
docs |
This docs site. |
The examples below leave out the model. To run them, provide a LanguageModel
Layer, for example from a model provider in openbot.
@openbot-build/core runs Bots. It has these main parts:
| Module | What it does |
|---|---|
Bot |
A Bot definition: tools, plugins, providers, compaction, and approvals. One definition can serve many Chats. |
ChatRuntime |
Runs Turns: deliver, execute, stream, interrupt, drain, wait, and more. |
ChatStore, ChatStoreRepository, Store |
Save Chats: a small record for each Chat, and a log of Items that only grows. |
ChatStoreMemory |
A store that keeps everything in memory, for tests and scripts. |
Chat, Item, Turn, ChatState |
The data types. Chat also has the Approvals service. |
ContextCompaction, SummaryCompaction |
Make a long history shorter, so that it fits the model. |
Plugin, PluginComposition, PluginState |
Make and combine plugins, and give them saved state. |
Skill, SkillMap |
Describe skills and keep a list of them. |
A Chat is one conversation with no end. Each input goes to its inbox. It starts a Turn, or it steers the running Turn. A Turn can have many model steps and tool calls. A Turn never waits: it runs, and then it ends. The store keeps the Items of a Chat in a log that only grows, so a long Chat stays cheap to save.
import { ChatRuntime, ChatStoreMemory, Store } from "@openbot-build/core"import { Effect } from "effect"import { Toolkit } from "effect/ai"
const program = Effect.scoped( Effect.gen(function* () { const definition = yield* Store.define({ stores: {}, schemaVersion: "chat/1", }) const store = yield* ChatStoreMemory.make(definition) const runtime = yield* ChatRuntime.make({ store, bot: { toolkit: Toolkit.empty }, }) const view = yield* store.create({}) return yield* runtime.execute({ chat: view.chat.id, input: "Explain closures", }) }),)Give the model and tool services to ChatRuntime.make when you build the
runtime. Its methods keep those services, including for later Turns from the
inbox. Pass optional features as bot.approvals, bot.compaction, and
operations; a service in the caller’s context does not turn a feature on.
drain returns Option<Delivery>, with None when there is no input to start.
ConfigurationOperations.contextLimits returns Option<Config.ContextLimits>.
Live.check returns an Effect that fails with LiveError for an unsupported
capability.
store.modifyEffect retries a write conflict up to five times when no
expectedRevision is given. Each retry reads the current Chat and runs the
update again, so the update must have no outside effects. An explicit
expectedRevision fails on conflict without a retry.
Operation.Store methods fail with Operation.StoreError on a store failure.
get and finish return Option<Record>; None means no matching record or
no new end. Each call can use RunInput.acquire to hold resources in its Scope
through the work, stored end, and notice. If the first write fails, the call
stays in its Turn. If the end write fails, the Chat still gets its notice, and
the failure is logged.
The independent core runtime runs in one process. Each Turn opens its plugin
resources in a new Scope. Background calls hold the resources they still need
until they end. core does not send heartbeats or cancel requests to other
processes, and it does not resume work after a process stops. The server uses
its durable execution system for that work.
execute starts a Turn, waits for it to end, and returns the Chat. stream
gives events while the Turn runs. Both fail when a Turn already runs in the
Chat.
deliver gives input to a Chat in one step, by the rules of the
inbox. It starts a Turn when the Chat is idle,
steers the running Turn when the input has the same author, and waits in the
inbox otherwise. It returns started, steered or queued. After each Turn,
the next input in the inbox starts the next Turn.
Each Turn runs in its own fiber, and the runtime owns that fiber. A fiber is a
light thread of Effect. When the Scope of the runtime closes, every running Turn
stops and ends as Interrupted. This is also true for a Turn that a caller
reads with stream or execute. Input that waits in the inbox stays there, and
a new runtime starts it with drain. When a caller of stream stops reading
early, its Turn stops too.
events gives the events of one Chat. With after, it first gives the recent
events after that revision, so a new subscriber can catch up. The runtime keeps
the last 64 events for this. A slow subscriber does not make memory grow: when
1024 events wait for it, each new event pushes out the oldest one. So such a
subscriber can miss events, but it always gets the newest ones.
Approvals
Section titled “Approvals”A tool with needsApproval asks before each call. The runtime then asks the
Approvals service. Give it as approvals in the Bot definition. It answers
each call with one of three decisions:
Chat.Decision.Allow()runs the call now.Chat.Decision.Deny({ reason })does not run the call. The model sees the reason.Chat.Decision.Pending({ result })does not run the call now. The model getsresultat once, and the Turn goes on.
Without the service, every such call is pending. So a Turn never waits.
When a decider allows a pending call later, give the call back with deliver
and approved. The ID of the call must be new. The Turn that reads the input
runs the call, and it does not ask again. Mark input from your program with
notice, so that the model knows that it does not come from a person:
import { Chat } from "@openbot-build/core"
const runtime = yield * ChatRuntime.make({ store, bot: { toolkit, approvals: { decide: (request) => Effect.succeed( Chat.Decision.Pending({ result: { pending: request.approvalId } }), ), }, }, })
yield * runtime.deliver({ chat: view.chat.id, input: "[Host] A person allowed the email.", notice: { kind: "request", ref: "req-1" }, approved: { id: "approved-1", name: "SendEmail", params: { to: "team@example.com" }, }, })Bot.Definition.registerTools opens tools in the Turn scope. It returns tool
definitions, their handler context, instructions, and step hooks. It has no
model continuation or event stream. All server features use this registration.
Their resources stay open while a background call uses them. The host applies
review and plan rules after all tools are registered. A tool cannot escape the
rules by being added later. Bot memory is read only when the model needs its
prompt, not when a background call rebuilds its tools.
SummaryCompaction.prepare builds summary input without calling a model. It
keeps recent messages and linked tool calls together. A TurnRunner host can
supply summarize(selection, input) to save the model result before it changes
the context. The standalone default uses the model of the current scope. The
server adapter uses the same call log and usage records as other model steps. A
saved summary can be read after takeover without another model call. Summary
text does not enter the reply text events.
Plugins
Section titled “Plugins”A plugin registers tools, step hooks, and state. Plugin.make makes one. Its
constructor runs in the scope of each Turn phase. A queued host action starts a
new phase. Resources close when that phase ends, fails, or is cancelled. A
background tool holds its resources until its call ends. Construction errors
fail that Turn. Plugins cannot wrap or start the model loop.
import { Plugin } from "@openbot-build/core"import { Context, Effect } from "effect"
const logging = Plugin.make({ id: Plugin.Id.make("logging") })(() => Effect.succeed({ tools: {}, handlers: Context.empty(), beforeStep: ({ kind }) => (kind === "model" ? Effect.log("A model step started") : Effect.void ).pipe(Effect.as(true)), }),)PluginComposition.merge(a, b) builds a then b. Both before-step and
after-step hooks run in that order. A false before-step result skips later
checks and stops the Turn. Tool definitions and handlers from b take
precedence. ToolRegistration.merge applies the same rules to plain
registrations. A time limit is data for the runner. It uses the saved Turn start
time, so a restart cannot reset it. When limits are combined, the shortest one
applies.
A plugin can save data in the Chat record or its state. Pass plugin.stores to
Store.define, then use PluginState.make.
The plugin store has four operations. get reads its value. snapshot also
gives the Chat revision. setAt writes only if that revision still matches.
modify reads and changes the value in one operation.
The function given to modify returns { result } to leave the value alone,
{ result, value: Option.some(value) } to replace it, or
{ result, value: Option.none() } to remove it. A change that leaves the value
alone makes no write and sends no event. The function must be pure: it must not
read the clock, make IDs, or do I/O. A write conflict can run it again. For work
that needs I/O, first read snapshot, do that work outside modify, then use
setAt. An old revision fails; setAt does not repeat the outside work.
Skills
Section titled “Skills”Skill.Descriptor describes a skill. SkillMap.Catalog is a list of skills.
When two lists have the same skill ID, the later one wins. Your program finds,
loads, and runs the skills.
Plugins in openbot
Section titled “Plugins in openbot”@openbot-build/openbot has all plugins of OpenBot. The server uses its
plugins, such as the Bot, App, and work plugins, in every Turn. The work plugin
gives the Bot its todo list, its reminders and plan mode. You can also use the
Turn limit plugins with your own ChatRuntime.
Auth.handle(request) returns an Effect with the response. Chats.wait takes a
Duration.Input for its timeout, such as "30 seconds". The HTTP route still
takes timeoutMs as a number of milliseconds.
Memory
Section titled “Memory”BotMemory has the types and helpers of Bot memory. The
server uses them for the memory Bot and the memory API. The schemas of the
memory API are in api, as BotMemory.
BotMemory.apply takes an ids array with one new ID for each operation, in
the same order. BotMemory.edit takes a new ID string as id. The caller makes
these IDs before it calls the pure helpers.
Turn limits
Section titled “Turn limits”Three plugins put a limit on each Turn. You can use one of them, or merge them
with PluginComposition.merge. Each one counts only its own Turn.
Model steps
Section titled “Model steps”TurnLimiter stops a Turn after a set number of model steps. The first model
request counts as one step.
import { TurnLimiter } from "@openbot-build/openbot"
const runtime = yield * ChatRuntime.make({ store, bot: { plugin: TurnLimiter.make({ maxSteps: 4 }) }, })It fails with one of two errors:
| Error | When |
|---|---|
InvalidMaxSteps |
maxSteps is not a positive whole number. |
ModelStepLimitExceeded |
The Turn needs another model step after the limit. It has maxSteps and history. |
Tokens
Section titled “Tokens”TokenLimiter stops a Turn after it uses a set number of tokens. A token is a
small piece of text that the model reads or writes. The plugin adds the input
and output tokens of each model step. A model step that has started can finish.
The next model step fails when the total reaches maxTokens.
import { TokenLimiter } from "@openbot-build/openbot"
const plugin = TokenLimiter.make({ maxTokens: 100_000 })| Error | When |
|---|---|
InvalidMaxTokens |
maxTokens is not a positive whole number. |
TokenLimitExceeded |
The Turn needs another model step after the limit. It has maxTokens, usedTokens, and history. |
TimeLimiter stops a Turn after a set time. maxDuration takes a number of
milliseconds or a text such as "5 minutes". When the time is up, the plugin
stops the Turn at once. It also stops a model step or a tool call in the middle.
import { TimeLimiter } from "@openbot-build/openbot"
const plugin = TimeLimiter.make({ maxDuration: "5 minutes" })| Error | When |
|---|---|
InvalidMaxDuration |
maxDuration is not a positive, finite time. |
TimeLimitExceeded |
The Turn runs longer than the limit. It has maxDuration and history. |
Model providers
Section titled “Model providers”@openbot-build/openbot has three model providers. Each one turns a model
selection into a LanguageModel Layer. You give the API key. See
Models for how OpenBot uses them.
OpenAiCompatProvider
Section titled “OpenAiCompatProvider”import { Config, Model, Provider } from "@openbot-build/core"import { OpenAiCompatProvider } from "@openbot-build/openbot"import { Effect, Layer, Redacted } from "effect"import { LanguageModel } from "effect/ai"import { FetchHttpClient } from "effect/http"
const selection = Config.Selection.make({ provider: Provider.Id.make("openai-compat"), model: Model.Id.make("gpt-4.1"),})const model = Layer.unwrap( OpenAiCompatProvider.make({ max_output_tokens: 1024 }).model(selection),).pipe( Layer.provide( OpenAiCompatProvider.layerClient({ apiKey: Redacted.make(process.env.OPENAI_API_KEY ?? ""), apiUrl: "https://api.openai.com/v1", }), ), Layer.provide(FetchHttpClient.layer),)
const program = LanguageModel.generateText({ prompt: "Hello" }).pipe( Effect.provide(model),)Leave out apiUrl to use https://api.openai.com/v1. A selection’s
reasoningEffort and serviceTier replace reasoning_effort and
service_tier in the defaults.
OpenAiProvider
Section titled “OpenAiProvider”The same pattern, with OpenAiProvider and the provider ID openai. It uses
the Responses API. It also has ResponsesCompaction.layer(), which makes long
histories shorter with the native compaction of the Responses API.
import { OpenAiProvider, ResponsesCompaction } from "@openbot-build/openbot"
const services = Layer.merge( Layer.unwrap(OpenAiProvider.make().model(selection)), ResponsesCompaction.layer(),).pipe( Layer.provide(OpenAiProvider.layerClient({ apiKey })), Layer.provide(FetchHttpClient.layer),)Get the compaction service from this layer, then pass it to the Bot:
import { ContextCompaction } from "@openbot-build/core"
const runtime = Effect.gen(function* () { const compaction = yield* ContextCompaction.ContextCompaction return yield* ChatRuntime.make({ store, bot: { compaction } })}).pipe(Effect.provide(services))AnthropicProvider
Section titled “AnthropicProvider”The same pattern, with AnthropicProvider and the provider ID anthropic. It
uses the Messages API.
import { AnthropicProvider } from "@openbot-build/openbot"
const provider = AnthropicProvider.make({ max_tokens: 1024 })reasoningEffort can be low, medium, or high. serviceTier can be auto
or standard_only. Other values fail before any request.
protocol
Section titled “protocol”@openbot-build/protocol implements the OpenBot App Protocol. Publishers use it
to put an App on the web. Hosts use it to find, connect to, and call Apps.
import { Document, McpPublisher } from "@openbot-build/protocol"import { Effect, Schema } from "effect"import { McpServer, Tool, Toolkit } from "effect/ai"
const tools = Toolkit.make( Tool.make("Hello", { parameters: Schema.Struct({ name: Schema.String }), success: Schema.Struct({ greeting: Schema.String }), }),)const handlers = tools.toLayer({ Hello: ({ name }) => Effect.succeed({ greeting: `Hello, ${name}` }),})const origin = "https://apps.example.com"
const publisher = McpPublisher.layer({ publication: { card: { $schema: Document.cardSchemaUrl, name: "com.example/hello", version: "1.0.0", description: "Say hello", remotes: [ { type: "streamable-http", url: origin + "/mcp", supportedProtocolVersions: [Document.protocolVersion], }, ], }, cardUrl: origin + "/mcp/server-card", catalogUrl: origin + "/.well-known/ai-catalog.json", mcpUrl: origin + "/mcp", allowedOrigins: ["https://host.example.com"], }, register: McpServer.registerToolkit(tools).pipe(Effect.provide(handlers)),})Serve publisher with HttpRouter.serve and an HTTP server Layer. See
Protocol for the full guide.