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.
Install
Section titled “Install”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.comUse 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:
pnpm add --save-exact @openbot-build/client@0.1.2 effect@4.0.7The client includes its shared OpenBot code and types under MIT. It needs no OpenBot source checkout. Other OpenBot packages keep their own licenses.
Make a client
Section titled “Make a client”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.
Call an operation
Section titled “Call an operation”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.
Connect a ChatGPT account
Section titled “Connect a ChatGPT account”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.
Observe and control executions
Section titled “Observe and control executions”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:
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.
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.
Handle errors
Section titled “Handle errors”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 asChatNotFoundorChatConflict. - 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.
Use the client in a browser
Section titled “Use the client in a browser”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.
Custom setup
Section titled “Custom setup”make({ baseUrl }) builds the Client service without the default parts. You
then give it:
- an
HttpClientservice, - 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/organizationsand/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.
Show App views
Section titled “Show App views”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.
Show confessions
Section titled “Show confessions”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
confidetool 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.