Skip to content

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:*".


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.

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 gets result at 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.

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.

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.


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

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.

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.

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.

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.

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

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.

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))

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.


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