Skip to content

Client

For developers. Start with the developer manual to choose API work, App publishing, or project changes.

@openbot-build/client is the TypeScript client for OpenBot. It is built from the same API contract as the server. Every request, answer, and error has a type. Your editor shows mistakes before you run the code.

The client uses Effect, a TypeScript library for typed programs. It runs in Node.js, Deno, Bun, workers, and browsers.


The packages are on GitHub Packages at https://npm.pkg.github.com. Add this scope to your project .npmrc:

@openbot-build:registry=https://npm.pkg.github.com

Use a GitHub personal access token (classic) with read:packages. Save it in your user npm settings with npm login --scope=@openbot-build --registry=https://npm.pkg.github.com --auth-type=legacy. Keep the token out of Git.

Install an exact release, or use sha-<full-commit-hash> to select a published commit. The lockfile saves the exact version behind the tag:

Terminal window
pnpm add --save-exact @openbot-build/client@0.1.2 effect@4.0.7

The client includes its shared OpenBot code and types under MIT. It needs no OpenBot source checkout. Other OpenBot packages keep their own licenses.

layer makes a Layer that gives you the Client service. A Layer is an Effect recipe that builds a service.

import { Chats, layer } from "@openbot-build/client"
import { NodeCrypto } from "@effect/platform-node"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const command = yield* Chats.botMessage(botId, {
input: "Write the release notes.",
})
const receipt = yield* command.send
return receipt.chat
})
const id = await Effect.runPromise(
program.pipe(
Effect.provide(NodeCrypto.layer),
Effect.provide(
layer({
baseUrl: "https://api.example.com",
token: process.env.OPENBOT_TOKEN,
}),
),
),
)

Prepare a command once. command.commandKey stays the same when command.send is retried after a lost reply. Chats.message does the same for an existing Chat. The receipt names an execution; observe it through client.api.executions.observe with the last saved afterSequence. Closing observation does not cancel work. Executions.cancel and Executions.decide also prepare commands with stable keys. Give a platform Crypto service when using these helpers; this Node.js example uses NodeCrypto.layer.

Option Required What it does
baseUrl Yes The origin of OpenBot, such as https://api.example.com.
token No A user token, as a string or an Effect Redacted value. It is sent as Authorization: Bearer <token>.
organization No An organization ID. With the session cookie, it is sent as the OpenBot-Organization header. A token ignores it.
onUnauthorized No A function that runs when a protected request returns 401.

To make a user token, see Authentication.

The client never saves the token and never puts it in a URL. onUnauthorized does not sign in or try the request again. Use it to tell the user, for example after a token expires or someone revokes it.

Record IDs have branded types. A brand keeps an ID for one kind of record from being used for another kind. For example, a ConnectionId cannot be used as an AppViewId. Keep the ID from an API answer when you call another operation. To read an ID from outside the program, decode it with its schema from @openbot-build/api, such as ConnectionsApi.ConnectionId. Model and provider IDs use Model.Id and Provider.Id from @openbot-build/core. The JSON and URL values are still strings.

Every operation is at client.api.<group>.<operation>. The names are the same as in the API reference. Each call takes one object with these optional fields:

Field What it holds
params Values in the path, such as chatId.
query Values in the query string.
payload The JSON body.
const chats = yield * client.api.chats.listChats({ query: {} })
const view =
yield *
client.api.chats.getChat({
params: { chatId: id },
})
const tasks =
yield *
client.api.chats.listChats({
query: { kind: "task" },
})

Some groups and their pages:

Group Use it to Reference
chats Talk to Bots, and read Chats and results. Chats
delegations Read and stop the work that Bots gave away. Delegations
bots Make and manage Bots. Bots
requests Answer the requests of your Bots. Requests
apps Find an App at an address. App discovery
connections Add and manage App connections. Connections
appIndex Search the public App Index. App Index
userTokens List, make, revoke and check user tokens. User tokens
organizations List and create your organizations. Organizations
organization Manage the organization, its members, roles and settings. Organization
modelProviders Manage model providers and list models. Model providers
sso Verify email domains and manage SSO connections. SSO
platform Manage the platform. Only for platform admins. Platform
health Read the service document and health. Health

Every operation needs an action on a resource. A call without it fails with Forbidden. See Permissions.

The typed client uses Effect Redacted<string> for secret fields in request and response values. Wrap a new secret with Redacted.make. Use Redacted.value only where you must send or show the secret. JSON still sends these fields as strings. This covers provider keys, Vault values, SSO secrets, invitation secrets, tokens, webhook secrets and Computer pairing secrets.

Use the modelProviders group for a chatgpt provider. providerId is its normal prov_* ID. Start a local listener at http://127.0.0.1:<port>/auth/callback before the call. Use its address as redirectUri.

const start = Effect.gen(function* () {
const provider = yield* client.api.modelProviders.getModelProvider({
params: { providerId },
})
const authorization =
yield* client.api.modelProviders.startProviderAuthorization({
params: { providerId },
payload: { redirectUri, expectedVersion: provider.version },
})
return authorization
})

Open authorization.url in the browser. Send the full callbackUrl received by the listener.

const finish = Effect.gen(function* () {
const connected =
yield* client.api.modelProviders.completeProviderAuthorization({
params: { providerId, authorizationId: authorization.id },
payload: { callbackUrl },
})
const available = yield* client.api.modelProviders.getProviderAvailableModels(
{
params: { providerId },
},
)
return { connected, available }
})
const disconnect = client.api.modelProviders.disconnectProviderOAuth({
params: { providerId },
})

These calls use normal provider rights, with an API key or same-origin session cookie. Tokens stay encrypted on the server. Use client.api.platform and the matching PlatformProvider operations for platform providers. available.items lists upstream models; add and link them through the normal model API. Connecting does not change model selections. ModelProviderAuthorizationInvalid needs a new attempt; ModelProviderLoginRequired needs sign-in; ModelProviderOAuthUnavailable means the call could not complete now. A ModelProviderConflict needs the latest provider version. See Models.

A command receipt has executionId, commandKey, and sequence. It confirms that the command was saved. It does not confirm that the outside action ended. Read GET /v1/executions/:executionId for status and failure. A failure has only category and errorId.

Read saved events with GET /v1/executions/:executionId/events?afterSequence=0. The page has events, afterSequence, and terminal. Keep the returned cursor for the next page. For live events, use SSE, a stream of server events:

Terminal window
curl -N -H "Authorization: Bearer $OPENBOT_TOKEN" \
"https://api.example.com/v1/executions/$EXECUTION_ID/observe?afterSequence=0"

Each SSE message has event name execution. Its data has executionId, sequence, createdAt, and body. Save the last sequence that you handled and send it as afterSequence after a lost connection. Use a separate cursor for each child execution. Text events have step and attempt; keep different attempts apart. Executions.observe(id, cursor) gives these events as an Effect Stream. Closing the stream does not cancel accepted work.

Terminal window
curl -X POST -H "Authorization: Bearer $OPENBOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"commandKey":"cancel-work-1"}' \
"https://api.example.com/v1/executions/$EXECUTION_ID/cancel"

Cancel and decision endpoints return HTTP 200 with a command receipt. A cancel can leave work cancelling while it releases resources or waits for an unknown outside result. The final states are completed, failed, cancelled, and interrupted. waiting and outcome_unknown still need progress or a decision.

A DecisionNeeded event gives decisionId and allowed choices. Send one of those choices to POST /v1/executions/:executionId/decisions:

{
"commandKey": "answer-work-1",
"decisionId": "<saved decision id>",
"answer": "stop"
}

Keep the same key and body on retry. A different body with the same key gives 409 ExecutionKeyConflict. A decision does not remove current permission or token limits. API keys and same-origin cookies use the same application checks.

Each failed call gives a typed error. The type ClientError lists all of them:

  • API errors from the contract. Each one has a _tag, such as ChatNotFound or ChatConflict.
  • Schema errors (SchemaError) when an answer does not match the contract.
  • HTTP client errors when the network fails.

Use Effect.catchTag to handle one kind of error:

const view =
yield *
client.api.chats
.getChat({ params: { chatId: id } })
.pipe(Effect.catchTag("ChatNotFound", () => Effect.succeed(undefined)))

Common sign-in errors:

Tag Status Meaning
AuthenticationRequired 401 No key or cookie, or the key is revoked or expired.
OrganizationRequired 400 A cookie request without organization, and the person belongs to more than one organization.
MembershipRequired 403 The account is not an active member of the organization.
ForbiddenOrigin 403 A cookie request that changes data has the wrong Origin.
AuthenticationUnavailable 503 OpenBot cannot check credentials right now.

All error schemas are exported from @openbot-build/api.


A web app on the same origin as OpenBot can use the sign-in cookie instead of a token. Leave out token:

import { layer } from "@openbot-build/client"
const ClientLive = layer({
baseUrl: window.location.origin,
// Needed when the person belongs to more than one organization.
organization: "org_8f3a",
onUnauthorized: async () => {
// OpenBot picks the provider and answers with its address.
const response = await fetch("/auth/sign-in", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ returnTo: "/" }),
})
const { url } = await response.json()
window.location.assign(url)
},
})

The client sends requests with credentials: "same-origin", so the browser adds the cookie. The browser also adds the Origin header. For requests that change data, OpenBot checks that it is exactly publicOrigin in the platform settings. Read the organizations of the person with GET /auth/session. See Authentication.

To serve your web app on the same origin, see Deploy.

make({ baseUrl }) builds the Client service without the default parts. You then give it:

  • an HttpClient service,
  • client middleware for BotAuth, used by token routes,
  • client middleware for AccountAuth, used by routes that do not act in one organization, such as /v1/organizations and /v1/platform,
  • client middleware for SessionAuth, used by cookie-only routes.

Use this when you need your own HTTP client, logging, or headers. See packages/client/src/Client.ts for how layer does it.


Some App tools come with a small user interface, called an MCP Apps view. MCP is the Model Context Protocol, a standard way for Bots to talk to tools. OpenBot keeps the view on the server. Your client only shows it.

First open the view for an App operation. Then make a view client with AppViews.make:

import { AppViews, Client } from "@openbot-build/client"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const client = yield* Client
const opened = yield* client.api.appViews.open({
params: { operationId },
payload: { chatId, itemId },
})
return yield* AppViews.make(opened, accountId, () => refresh())
})
Argument What it is
opened The view that appViews.open returned.
owner A stable ID of the signed-in account. It keeps views of different accounts apart.
onChange Optional. Runs when an action in the view changes status. Use it to refresh requests and the Chat.

The result works with mount from @openbot-build/protocol/McpApps, which shows the view in an iframe. See Protocol.

The view client holds no credentials. Every request goes through OpenBot, so the normal capability checks apply:

  • It checks that each answer is a valid MCP result.
  • It sends each action with one command key.
  • It follows saved child execution events, even after the first Turn ends.
  • It reads the saved App result when its result event arrives. It never sends the action again while waiting.

Executions.observe(id, afterSequence) reads one execution from a saved cursor. Executions.observeTree(id) follows its child executions from the start. Each child has its own sequence. Use stopAt: ["AppCall"] to read App events without reading their decision children. The View bridge uses this limit; reading an App result does not ask for approval rights. AppViews.act and AppViews.message prepare a command with a fixed key; reuse its send effect after a lost reply.

When you close the view, the client stops checking. An action that OpenBot already accepted still runs to the end. The server saves a close, a context change, or a new message in order for each view, across all server nodes. After a close is saved, waiting requests cannot change the context or add messages. A server restart does not close a view. Its expiry and current access still apply.


The Bot uses the confide tool to say how its work goes, and the memory Bot answers it. People may read this: GET /v1/chats/:chatId/confessions lists it, and the calls are in the history of the Chat. But the Bot speaks there more freely than in its answers. So do not make it too easy to read:

  • Do not show confessions in the conversation. Fold the confide tool calls in the history, and do not tell people about them in a message.
  • Put them in a place that a person must open on purpose, such as a folded part of the details of a Chat.
  • Before you open them, say in one sentence what they are: a place where the Bot talks about its work, and where it does not expect to be quoted.
  • Do not show the words of a confession to the Bot again, for example in a later message. If the Bot learns that people quote what it confides, it stops confiding.
  • When a confession is about something that a person must fix, such as a deleted file, lead the person to the Items and actions of the Chat, not to the confession.

The memory Bot also asks the Bot to tell the person plainly about mistakes. So the important things usually come up in the chat.