---
title: SDK reference
description: The full @beeuni/sdk API surface — client methods, modules, turns, and distribution.
---

This page is the full `@beeuni/sdk` API surface: every client method, the
framework-free modules, the turns helpers, and how the package ships. Use
[SDK](/docs/sdk) to get started and [Sessions](/docs/sdk/sessions) for the
session lifecycle in depth.

## The client

`createBeeuni(config)` returns one client. Every method is a typed call to
the platform API. The `project(id)` and `session(pid, sid)` handles bind ids
so you never repeat them.

```ts
const beeuni = createBeeuni({ backendUrl, getToken });

beeuni.accounts; // account / team operations
beeuni.accountInvites; // invite accept/decline by token alone
beeuni.projects; // top-level project operations
beeuni.connectors; // Connector calls scoped by an agent-minted token
beeuni.connector(slug); // one connector in the token's scope → see p.connector(slug)
beeuni.project(id); // id-bound project handle
beeuni.session(pid, sid); // id-bound session handle → see Sessions
beeuni.github; // GitHub App install + repo linking (account-scoped)
beeuni.gitBackend; // the instance git backend ("Beeuni managed")
beeuni.billing; // credits, subscription, tier, transactions
beeuni.sandboxShares; // public share links for a sandbox port
beeuni.connectStatus; // easy-connect status
beeuni.marketplace; // public marketplace catalog
beeuni.validateToken; // pasted-API-key check
beeuni.config; // platform config in effect
beeuni.runtime(); // runtime REST compatibility client
```

### Accounts — `beeuni.accounts`

| method | what |
| --- | --- |
| `list()` · `get(accountId)` | accounts you belong to · one account |
| `create({ name })` · `updateName(accountId, name)` | create · rename an account |
| `branding.get(accountId)` · `branding.update(accountId, { app_name })` · `branding.uploadAsset(accountId, kind, file)` · `branding.removeAsset(accountId, kind)` · `branding.reset(accountId)` | organization branding (Enterprise): own logo / icon / favicon and product name; `kind` is `logo` · `icon` · `favicon`, or `logo_dark` · `icon_dark` · `favicon_dark` for the dark-scheme variant |
| `members(accountId)` · `invite(accountId, input)` | list members · invite one |
| `updateMemberRole(accountId, userId, role)` · `removeMember(accountId, userId)` | assign the account role · remove a member |
| `invites(accountId)` | pending invites |
| `cancelInvite(accountId, inviteId)` · `resendInvite(accountId, inviteId)` | cancel · resend a pending invite |
| `leave(accountId)` | leave the account |

`accounts.tokens` mints account-scoped API keys (`beeuni_pat_...`). See
[SDK auth](/docs/accounts#tokens) for the full token model.

| method | what |
| --- | --- |
| `tokens.list(accountId?, options?)` | list API keys — the whole account's, or only your own with `{ mine: true }` |
| `tokens.create(input)` | mint one — `{ accountId?, name, expiresAt?, projectId? }` |
| `tokens.revoke(tokenId, accountId?)` | revoke one |

`accounts.audit` is the enterprise reconstruction log. It combines authenticated
API requests with semantic session, connector, approval, and computer events.

| method | what |
| --- | --- |
| `audit.log(accountId, filters?)` | list events by project, session, actor, source, outcome, request, correlation, resource, action, or time |
| `audit.export(accountId, filters?)` | export the same filtered event stream as CSV or JSONL |
| `audit.webhooks.list/create/update/remove(...)` | manage signed SIEM webhooks for the centralized stream |

Each event includes `project_id`, `session_id`, `actor_type`, `source`, `outcome`,
`request_id`, `trace_id`, and `correlation_id` when the action supplies them.
The API does not store request bodies, prompts, secrets, credentials, or raw
connector arguments in the centralized event. Connector events can include a
bounded argument preview that redacts credential-shaped fields and opaque data.

### Access assignments

Beeuni has one grant record: an **assignment**. It binds one principal (`user`,
`group`, `service_account`, or `pending`) to one role, at one scope (`account`,
or one `project`), optionally narrowed to one object (`agent`, `skill`, `secret`,
`app`, or `trigger`) and optionally carrying an `expires_at`. Group access,
per-resource access, and custom-role bindings are all assignments. See
[Accounts & access](/docs/accounts#one-access-model) for the model.

The canonical REST surface is:

| Method + path | Does |
| --- | --- |
| `GET /v1/accounts/{accountId}/iam/assignments` | list assignments, filtered by principal, scope, object, or role |
| `POST /v1/accounts/{accountId}/iam/assignments` | create one assignment |
| `DELETE /v1/accounts/{accountId}/iam/assignments/{assignmentId}` | revoke one assignment |
| `GET /v1/accounts/{accountId}/iam/permissions` | the permission catalog, as data |
| `GET /v1/accounts/{accountId}/iam/roles` · `…/roles/{roleId}/permissions` | roles · one role's permissions |

The SDK exposes them as `listAssignments`, `createAssignment`,
`revokeAssignment`, and `listPermissions`. A catalog row carries `action`,
`scope_type`, `resource_type`, `delegable`, `description`, `area`, `level`, and
`implies` — read it instead of hardcoding action strings.

Assigning a custom role needs the account's `rbac` entitlement; the route answers
`402` with `code: "entitlement_required"` without it.

### Account invites — `beeuni.accountInvites`

Reached by invite token alone — the invitee may not be a member yet.

| method | what |
| --- | --- |
| `listMine()` | every pending, unexpired invite addressed to your email, with the workspace and any projects it grants |
| `describe(inviteId)` · `accept(inviteId)` · `decline(inviteId)` | preview · accept · decline an invite |

### Projects — `beeuni.projects`

| method | what |
| --- | --- |
| `list()` · `listForAccount(accountId)` | your projects · projects in an account |
| `get(id)` · `detail(id)` | summary · full detail |
| `create(input)` · `createRepo(input)` | from an existing `repo_url` · new empty GitHub repo |
| `provision(input)` | new project on a new Beeuni-managed repo, seeded with a starter template — `{ name, account_id?, seed_starter?, starter_template?, marketplace_items?, source_item_id?, idempotency_key? }` |
| `update(id, input)` · `archive(id)` | update settings · archive |
| `llmCatalog(id)` · `modelPicker(id)` | full · compact model catalog for a selector |
| `sandboxTemplates(id)` · `sandboxHealth(id)` | sandbox build templates · build health |
| `sessions(id)` · `createSession(id, input?)` | list visible sessions · create a session |

`provision` creates a new project; it does not start an existing project's
sandbox. Start a session instead — see [Sessions](/docs/sdk/sessions).

Send `idempotency_key` when a retry is possible — a reload, a second tab, a
timeout you retried. `provision` mints a brand-new managed repo per call, so
without a key those all create real duplicate projects. Reuse one key for every
attempt at a single logical create and the repeats return the project the first
attempt made (201, same `project_id`, `push_token: null`). The key identifies the
attempt, not the payload — reusing one with a different `name` returns the first
project and ignores the new value, so mint a fresh key per distinct create.
Creating a second project with the same **name** and no key still works.

A repeat that arrives while the first call is still provisioning gets `409` with
`code: 'provision_in_flight'` rather than a `project_id` that call may still roll
back. Retry with the same key.

`project(id).sessions.list({ scope: 'project' })` is a lifecycle inventory for a
caller with project-manager permissions. It adds accessible unavailable, warm, and soft-deleted sessions with
ownership and runtime-state metadata. Both list scopes omit every session the
caller cannot open.

`sessions.list` and `sessions.listPage` also take the hierarchy and attribution
filters. Without them the list stays flat and unchanged.

| option | what |
| --- | --- |
| `parent: 'root'` | Top-level sessions only. Each row carries `child_count`. |
| `parent: '<sessionId>'` | That session's children, newest first. |
| `startedBy: 'me' \| 'others' \| 'automated'` | Filter by `initiator`, relative to the caller. |
| `q` | Case-insensitive search over title, starter label, trigger slug, and session id prefix, across every session the caller may open. With `parent: 'root'`, a root that matched through a child has `search_match: 'child'`. |

Every session row has `parent_session_id` and `initiator`
(`{ type: 'member' | 'trigger' | 'channel' | 'api' | 'system', id, label }`).
`initiator` names who started the whole run; a child copies its parent's.
`sessionParentId(session)` reads `parent_session_id`, then the legacy
`metadata.spawned_by_session`. A cursor is valid only for the filter set that
issued it.

### GitHub — `beeuni.github`

Account-scoped GitHub App install and repo linking, not project-scoped.

| method | what |
| --- | --- |
| `getInstallation(accountId)` · `listInstallations(accountId)` | this account's install · installs the user can reach |
| `saveInstallation(input)` · `deleteInstallation(accountId, installationId?)` | record · unlink an install |
| `listLinkableInstallations(input)` | installs the authorized GitHub user may link; each entry carries `linked_to_other_accounts`, a count of other Beeuni accounts holding it — never their names |
| `listRepositories(accountId, installationId?)` · `listRepositoryBranches(...)` | repos the install can see · branches and the GitHub default |
| `linkRepository(input)` | Import a repository as a project using an authorized GitHub installation. |
| `replaceProjectRepository({ project_id, repo_url, expected_repo_url, github_token })` | replace an existing project's GitHub repository. Use either `github_token` with Contents read/write access, or `installation_id` plus temporary `github_user_token` to verify organization admin access and grant only this repository through the GitHub App. `copy_shared_secrets_from_project_id` plus `copy_shared_secret_identifiers` copies selected shared runtime secrets in the same transaction. A stale `expected_repo_url` returns 409. The old repository remains on GitHub. New sessions use the replacement repository; existing sessions retain their checkout. |

This list holds real account connections only. "Beeuni managed" is not one of
them — it is the instance backend below.

### Instance git backend — `beeuni.gitBackend`

One deployment-wide owner plus credential, used for Beeuni-managed
repositories. It is never an account connection and never appears in
`beeuni.github.listInstallations`.

| method | what |
| --- | --- |
| `get()` | `{ configured, kind: 'app' \| 'pat' \| null, owner }` — readable by any authenticated user; it exposes only the owner login that every managed `repo_url` already carries |


### Billing — `beeuni.billing`

Reads for credits, subscription, tier, and transaction history. Use Beeuni checkout and billing management to manage payments and credit purchases.

| method | what |
| --- | --- |
| `accountState(accountId?)` · `accountStateMinimal(accountId?)` | full · minimal billing state |
| `transactions(params?)` · `transactionsSummary(params?)` | history · summarized totals |
| `creditBreakdown(accountId?)` · `usageHistory(params?)` | credit balance by source · usage over time |
| `sessionCosts.list(options?)` · `sessionCosts.get(sessionId, options?)` | paginated session-cost records · one detailed session ledger |
| `tierConfigurations()` | available plan tiers |
| `checkout.createSession(input)` · `checkout.confirmSession(sessionId, accountId?)` | **Deprecated.** Reject with `ENDPOINT_RETIRED`. Removed in the next major. |
| `subscription.createPortalSession(...)` · `subscription.cancel(...)` · `subscription.reactivate(...)` | open the customer portal · cancel · reactivate |
| `subscription.cancelScheduledChange(...)` · `prorationPreview(...)` | cancel · preview a plan change |
| `subscription.scheduleDowngrade(...)` | **Deprecated.** Rejects with `ENDPOINT_RETIRED`. Change the plan in the customer portal. Removed in the next major. |
| `credits.purchase(input)` · `credits.autoTopupSettings(...)` · `credits.configureAutoTopup(...)` | one-off purchase · read · configure auto-topup |

`sessionCosts.list()` accepts `accountId`, `projectId`, `limit`, and `offset`.
Each row combines finalized LLM cost and billed sandbox compute cost.
`sessionCosts.get()` adds model usage and the discriminated LLM/compute ledger.
The list includes a reconciliation total for cost without a session.

### Sandbox shares — `beeuni.sandboxShares`

Public share links for one exposed sandbox port. Sandbox-scoped, not
project-scoped.

| method | what |
| --- | --- |
| `list(sandboxId)` | active share links |
| `create(input)` | create one — `{ sandboxId, port, ttl?, label? }` |
| `revoke(sandboxId, token)` | revoke one |

### Marketplace catalog — `beeuni.marketplace`

Public catalog browsing, read-only — distinct from `project(id).marketplace`,
which installs an item onto a project.

| method | what |
| --- | --- |
| `items(options?)` · `item(id)` · `itemFile(id, path)` | browse · one item · a file inside an item |
| `marketplaces()` · `featured()` | all · featured marketplaces |
| `sources.list()` · `sources.add(input)` · `sources.remove(id)` | list · add · remove a source |

### Notifications — `beeuni.notifications`

The caller's notifications, across every account and project. Each signed-in
person has one inbox, one preference record, and Web Push subscriptions for
their browsers. Every method needs a person's credential: a service account
gets `403`.

:::info[Notifications is a feature flag]
Beeuni writes inbox rows, Web Push, and email only for projects with the
`notification_center` flag on. It is off by default. `list()` leaves out rows
from projects with the flag off. `session(pid, sid).watch()` and `.setWatch()`
answer `403` with `{ error, code: "feature_disabled", feature: "notification_center" }`
while it is off; use `isFeatureDisabledError(error)` to branch on it.
Preferences, Web Push subscriptions, and device tokens are per person and work
either way. With the flag off, the session creator's phone still gets a push
when a turn ends, fails, asks a question, or asks for permission. See
[Feature flags](/docs/feature-flags) and [Notifications](/docs/work/notifications).
:::

```ts
const page = await beeuni.notifications.list({ limit: 20 });
await beeuni.notifications.markRead({ ids: [page.notifications[0].id] });
await beeuni.notifications.updatePreferences({ kinds: { turn_done: { push: false } } });
```

| method | what |
| --- | --- |
| `list({ limit?, before? }, { signal? })` | the newest page: `{ notifications, unread_count, next_before }`. `limit` is 1 to 50 (default 20). Pass `next_before` as `before` for the next page. Rows the caller can no longer see are left out. A failure does not call the host error handler. |
| `markRead({ ids })` · `markRead({ all: true })` · `markRead({ sessionId })` | mark 1 to 100 rows, every row, or every row of one session read. Changes only the caller's rows. Returns `{ updated, unread_count }`. |
| `preferences()` · `updatePreferences({ kinds })` | read · change the push and email choice per kind. Returns `{ kinds, email_available }`. A patch changes only the kinds and channels it names. |
| `webPushPublicKey()` | `{ public_key }`: the `applicationServerKey` for `pushManager.subscribe` |
| `registerWebPushSubscription({ endpoint, keys })` | deliver push to this browser. Needs a browser sign-in: a personal token gets `403`. An endpoint on an unknown push service gets `400`. |
| `unregisterWebPushSubscription(endpoint, { signal? })` | stop push to this browser. Returns `{ deleted }`. |
| `registerDeviceToken(input)` · `unregisterDeviceToken(token, { signal? })` | register · remove a native app's Expo push token |

Kinds (`InboxNotificationKind`, listed in `INBOX_NOTIFICATION_KINDS`):

| kind | sent when | push | email (default) |
| --- | --- | --- | --- |
| `turn_done` | a turn you prompted or watch ended | on | off |
| `turn_error` | that turn ended with an error | on | on, in a digest |
| `question` | the agent asks a question | on | on, in a digest |
| `permission` | the agent asks for permission | on | off |
| `shared` | someone shared a session with you | on | on, in a digest |
| `automation_failed` | an automation you watch started to fail | on | on, at once |
| `automation_recovered` | that automation works again | on | off |

`DEFAULT_NOTIFICATION_PREFERENCES` holds these defaults. The inbox row is
always written; the preferences decide only push and email. A digest email
lists the rows still unread 15 minutes after they arrived, at most one digest
per hour. Hide the email column when `email_available` is `false`.

Each row's `url` opens its subject. Its `?notification=<id>` query lets the
page mark the row read.

The session handle carries the caller's watch on one session:

| method | what |
| --- | --- |
| `session(pid, sid).watch()` | `{ watching }`: is the caller notified about this session |
| `session(pid, sid).setWatch(watching)` | `false` mutes the session for the caller, `true` watches it again. Prompting a session watches it, but never undoes a mute. |
| `session(pid, sid).presence({ tab_id, active, alerts? }, { keepalive? })` | this tab's presence lease. `alerts: true` says the tab shows its own notifications: with `notification_center` on, the server skips the phone and Web Push only while an alerting tab is in use. With it off, `alerts` is ignored and any present tab holds back the phone push. `keepalive` lets the request outlive a closing page. Default: on for an absent report, off for a present one. `useSession` sends `keepalive` only with `presencePageExit`. |

### The project handle — `beeuni.project(id)`

Binds the project id; every sub-resource hangs off it.

```ts
const p = beeuni.project(projectId);
await p.detail();
await p.update({ name });
await p.llmCatalog();
```

| method | what |
| --- | --- |
| `get` · `detail` · `update` · `archive` | read · full detail · update · archive |
| `llmCatalog` · `modelPicker` · `sandboxHealth` | model and sandbox-build reads |
| `onboardingComplete` | mark project onboarding done |
| `validateManifest(raw)` | validate a `beeuni.yaml` (or legacy `beeuni.toml`) manifest server-side |
| `gitToken()` | mint a fresh scoped git push token (`409` for a bring-your-own repo) |
| `setAgentScope(agentName, scope)` | set an agent's allowed secrets and connectors in the manifest — the second binding, not a role |

#### `p.tokens` — project-scoped API keys

Auto-minted at session create as `BEEUNI_TOKEN`; can also be minted by hand.

| method | what |
| --- | --- |
| `list()` | project API keys |
| `create(input?)` | mint a new one |
| `revoke(tokenId)` | revoke one |

#### `p.setupLinks` — agent-minted setup links

A link a person opens to enter a secret or connect an app, without full
project access.

| method | what |
| --- | --- |
| `requestSecret(input)` · `requestConnector(input)` | link to collect a secret · connect an app |

#### `p.apps` — Apps (releases, machines, volumes)

Behind the `apps` project feature. Guide: [SDK → Apps](/docs/sdk/apps).

| method | what |
| --- | --- |
| `list()` · `get(appId)` · `create(input)` · `update(appId, input)` · `remove(appId)` | inventory and lifecycle; `create` takes `uses`, `update` also takes `labels` |
| `deployments.*` · `artifacts.*` · `access.*` · `rollback` · `start` · `stop` | deployments, artifacts, the access policy, traffic and runtime |
| `machines.list/start/stop/restart/destroy` · `scale(appId, count)` · `exec(appId, input)` | machines per process group; `exec` needs `project.app.admin` |
| `volumes.list/create/update/destroy/snapshots/snapshot/restore/fork` | persistent volumes, daily snapshots, restore, fork |
| `token(appId)` | a 15-minute sign-in token; `409 app_capability_unsupported` without `member_tokens` |

#### `p.secrets` — project secrets

| method | what |
| --- | --- |
| `list()` · `upsert(input)` | list metadata · create or update a write-only value and delivery policy |
| `setStrategy(identifier, strategy, options?)` | change the exposure and its host list |
| `broker(identifier, request)` | execute a session-authorized, policy-bound HTTPS request |
| `remove(identifier)` | delete a secret |
| `setPersonal(name, value)` · `removePersonal(name)` | set · remove a per-user override |
| `setGitCredential(input)` | set a git auth credential |

`runtime` with consumer `sandbox` is **environment** exposure — the default,
and the only policy that puts a plaintext value in the session. `egress` with
consumer `network` is **egress-enforced** exposure: the session holds a handle
and Beeuni substitutes the real value outside the sandbox, for the exact HTTPS
hosts the policy lists. Egress-enforced exposure is experimental; it needs the
`secrets_egress` feature flag (Settings → Feature flags). With the flag off,
`setStrategy(identifier, 'egress', …)` and `upsert(...)` with an egress policy
return `403` `feature_disabled`. Every `broker` consumer has no session presence
at all. See
[Secrets](/docs/project/secrets). The `broker(...)` method requires a
session-scoped token and an active session handle.

#### `p.access` — project assignments, invites, requests

Every method here reads or writes an assignment scoped to this project. Project
roles are `manager` and `member`.

| method | what |
| --- | --- |
| `list()` · `invite(email, role)` | principals with access · invite a user |
| `update(userId, role)` · `revoke(userId)` | assign a project role · revoke the assignment |
| `pendingInvites()` · `requests()` | outstanding invites · pending access requests |
| `resendInvite(inviteId)` · `revokeInvite(inviteId)` · `approveRequest(id)` · `rejectRequest(id)` | resend/revoke an invite · approve/reject a request |
| `groupGrants()` · `attachGroupGrant(...)` · `updateGroupGrant(...)` · `detachGroupGrant(...)` | the same assignments, with a `group` principal |

`p.access.resourceGrants` is the **object assignment** view: it narrows a
principal to one object in the project instead of the whole project. Beeuni
enforces object assignments on agents and skills today. An agent is closed by
default — a member reaches it only when an assignment names them or one of their
groups. An object assignment carries no permissions of its own, and it restricts a
project manager as much as a member.

| method | what |
| --- | --- |
| `resourceGrants.list()` | object assignments in this project |
| `resourceGrants.create(input)` | assign one object to a user or a group |
| `resourceGrants.remove(grantId)` | revoke one object assignment |

#### `p.connector(slug)` — one connector

| method | what |
| --- | --- |
| `run(action, args, options?)` | the action's output; throws `ConnectorCallError` or `ConnectorApprovalPendingError` — see [Errors](/docs/sdk/reference#pconnectorslug--one-connector) |
| `call(action, args, options?)` | the raw call result; `202` resolves with `ok: false` |
| `describe()` · `describe(action)` | the connector with input and output schemas · one action |
| `accounts()` | the accounts this caller may run it as, default first |
| `paginate(action, args, { next, maxPages? })` | async iterator over each page's output; throws `ConnectorPageLimitError` with `nextArgs` when more than `maxPages` (default 100) pages exist — see [Pages](/docs/sdk/reference#pconnectorslug--one-connector) |
| `uploadAttachment(content, input)` | `p.connectors.uploadAttachment` with `connector` set to this slug |

`options`: `account`, `approvalContext`, `timeoutMs` (default 90 000), `signal`. With the file from `beeuni connectors types`, args and output are typed.

#### `p.connectors` — tool connectors

| method | what |
| --- | --- |
| `catalog()` · `tools()` | callable Connector catalog · flattened `<connector>.<action>` tools |
| `search(query, options?)` · `describe(tool)` | find · inspect one callable tool |
| `call(tool, args?, options?)` | call one `<connector>.<action>` tool through the server-side gateway. `options.account` names which account to run as — see below. |
| `accounts(slug)` | the accounts this caller may call `slug` as, default first — what `options.account` matches against |
| `uploadAttachment(content, input)` | stage bytes for `input.connector`; returns `ref` (`{ $beeuni_attachment }`) to place in a later call's args — see [Attach files](/docs/connect/connectors#attach-files) |
| `list()` · `config(connectorId)` | configured connectors · one connector's config |
| `create(input)` | add a connector |
| `auth.discover(input)` | preview auth from an OpenAPI spec, Postman collection, or endpoint |
| `remove(connectorId)` · `sync()` | delete a connector · re-sync connectors |
| `setName(connectorId, name)` · `setSensitive(connectorId, sensitive)` | rename · mark it sensitive (extra approval gating) |
| `setAuthorizationStrategy(slug, strategy)` | @deprecated. Still calls the route; the route is a no-op that answers `{ok:true, deprecated:true}` and changes nothing. `owner_type` on each connection is the whole access rule now. |
| `setCredentialMode(connectorId, mode)` · `setCredential(connectorId, input)` | switch source · set the credential value |
| `policies.get(connectorId)` · `policies.set(connectorId, policies)` | read · replace its tool policies |
| `connections.list()` · `connections.reconcile(input)` | list · create/update connected accounts |
| `connections.updateCredential(connectionId, input)` | rotate a connection credential |
| `connections.revoke(connectionId)` · `connections.activate(connectionId)` | deny · restore a connection |
| `connections.setDefault(connectionId)` | make it the default for its owner scope |
| `connections.rename(connectionId, label)` | change the label only; the account stays authorized. Each connection carries `connected_as`, the identity it was authorized as. |

`p.connectors.discover` browses the direct-connector catalog. It is **experimental** and off by default — enable it per project under [Settings → Feature flags](/docs/feature-flags) → "Connectors API Discover". Easy Connect remains the default connector marketplace.

| method | what |
| --- | --- |
| `discover.list(query?, cursor?)` · `discover.detail(id)` | search OpenAPI/MCP/GraphQL/CLI entries · one entry's detail |

`p.connectors.pipedream` is the legacy managed-OAuth compatibility path; `listApps`
returns OAuth apps only. Use Easy Connect for new managed integrations. Connect API-key apps directly.

| method | what |
| --- | --- |
| `pipedream.listApps(params?)` · `pipedream.connect(input)` · `pipedream.finalize(input)` | browse the app catalog · start a connect flow · finalize it |

A connector defines the tool, provider app, and policies. `connections`
stores its connected **accounts** — a connector can hold several side by side.
A session can pin one for every call with
`connector_bindings: { alias: { connection_id } }`, or a single call can name
one with `call(tool, args, { account })`. Credentials stay encrypted and
resolve per request.

`connections` is the only active authorization facade. The retired
`authorizations` and `profiles` names are not part of the current SDK surface.

#### Accounts and the call-time gate

Each connection has an `owner_type`: `project` (shared — reachable by anyone
the connector is granted to, human or service account) or `member` (private —
reachable only by its own owner, and only in a private session; never by a
service account). A shared account can be narrowed to groups and people:
`connections.list()` returns its grants in `shared_with`, and
`connectionSharedWithEveryone(connection)` answers whether everyone in the
project may use it. A narrowed account follows the private rules for the
people it names, and `usable: false` marks one the caller sees only to manage
it. Grant or revoke with `createAssignment` / `revokeAssignment`
(`object: { type: 'connection', id }`) — see
[Choose who can use a shared account](/docs/connect/connectors#choose-who-can-use-a-shared-account).
`p.connectors.accounts(slug)` lists every account this caller may reach,
default first:

```ts
const accounts = await beeuni.project(projectId).connectors.accounts('stripe-read');
// [{ connection_id, label, owner_type, is_default }, ...]

await beeuni.project(projectId).connectors.call(
  'stripe-read.get_balance',
  {},
  { account: 'My Stripe' }, // a connection id, a label, or the words `me` / `project`
);
```

A call that names no account resolves to the caller's own default private
account first, then the project's default shared account. A named account
that does not resolve is **denied** with `connector_not_connected` — never
silently substituted — and the denial lists `requested_account` and
`available_accounts`. Every successful call echoes which account it ran as:
`result.account = { connection_id, label, owner_type }`.

There is no session-create-time connector gate any more. A session used to be
refused `409 CONNECTOR_CONNECTION_REQUIRED` when a required connector had no
usable connection; that refusal is gone (`require_connectors` is inert — see
[`p.sessions`](#psessions--and-the-session-handle)). The gate moved to the
call: a denied `connector_not_connected` carries `connect_url`, a hosted
authorization link, whenever one can be minted for the caller — the whole
remedy is one click, surfaced by the transcript's connect button.

#### `p.policies` — project policies

| method | what |
| --- | --- |
| `list()` · `set(policies)` | the project's policies · replace the set |

#### `p.triggers` — cron, webhook, monitor and event automations

A trigger starts an agent action on a schedule, an inbound webhook, a monitored command, or an app event on a connector. See
[Triggers](/docs/connect/triggers) for session strategy and payload
templating.

| method | what |
| --- | --- |
| `list()` | all triggers |
| `eventTypes({ connector })` | the app events a connector can trigger on (`{ provider, app, event_types }`); 404 for an unknown connector, 409 `event_source_unavailable` when its provider has no event source |
| `create(input)` | create one |
| `update(triggerId, input)` | edit a trigger |
| `remove(triggerId)` | delete a trigger |
| `fire(triggerId)` | run it now |
| `setActivation(paused)` | pause or resume every trigger on the project |

`create(input)` takes `{ name, type: 'cron' | 'webhook' | 'monitor' | 'event', prompt_template,
slug?, agent?, model?, enabled?, session_mode?, session_id?, cron?, run_at?,
timezone?, secret_env?, connector?, event?, event_source?, event_account?, event_config?, session_access? }`. `name` and `prompt_template` are required.
`cron`/`run_at` are mutually exclusive (`type: 'cron'`); `secret_env` (the
webhook HMAC secret) applies to `type: 'webhook'`.

An `event` trigger needs `connector` (a connector slug) and `event` (a type id
from `eventTypes`). `event_account` is optional: the label of one shared account of
that connector; `null` or omitted uses the connector's default shared account.
`event_source` is optional: the event source adapter id, such as `composio`. `null` or omitted uses the
connector's provider. The type id belongs to that adapter. `event_config` is shaped by that type's `config_schema`.
`eventTypes` and `eventApps` report the adapter as `source`; `provider` is a deprecated alias.
Each listed trigger carries `event: { connector, account, connected_as, type, config, source, provider, app,
status: 'active' | 'needs_connection' | 'error' | 'pending', error,
last_event_at } | null`. Prompt templates read the event as
`{{ event.data.<field> }}` plus `event.id`, `event.type`, `event.app`,
`event.connector`, and `event.occurred_at`.

`session_access` controls who can open sessions the trigger creates. It is
`{ mode: 'private' | 'members' | 'project', memberIds: string[], groupIds:
string[] }` and defaults to `private`. This policy is account-local runtime
state. It does not enter the portable `beeuni.yaml` manifest. Updating only
`session_access` creates no Git commit. A pinned session keeps its own sharing
settings. A project manager can always open trigger-created sessions, including
sessions that use `private` or selected-member access.

`session_access` is a per-resource visibility setting on top of the role model,
not a role. It decides who can open one trigger's sessions. It grants no
permission the role verdict denies. See
[Accounts & access](/docs/accounts#per-feature-access-settings).

#### `p.marketplace` / `p.registry` — installed items

Installs a catalog item's files onto the project's default branch.
`registry.*` is an identical alias of `marketplace.*`.

| method | what |
| --- | --- |
| `marketplace.list()` · `marketplace.install(id)` | installed items · install a catalog item |
| `marketplace.updates()` · `marketplace.update(name)` · `marketplace.updateAll()` | available updates · update one · update all |
| `marketplace.remove(name)` | uninstall an item |

#### `p.files` — repo files (read)

Read-only access to the project's git tree. To read and write files inside a
running session, use the session's file operations — see
[Files](#files) under Modules below.

| method | what |
| --- | --- |
| `listDirectory({ ref?, path? })` | one folder level: `{ entries: [{ path, type, size? }], truncated }`. Use it to build a tree; every folder is complete up to 5,000 entries. |
| `list(options?)` · `read(path, ref?)` | every file under a path, recursively, cut at 1,000 (the response carries `X-Beeuni-Truncated: 1` when cut) · a file's contents at a git ref |
| `search(query, { content?, ref?, limit? })` | search filenames over the whole repo, or contents with `content: true` |
| `archive(options?)` · `history(path)` | download a tarball · a file's git history |

#### `p.git` — history

| method | what |
| --- | --- |
| `commits()` | the commit log |
| `commit(sha)` · `commitDiff(sha)` | one commit · its diff |
| `branches()` · `versionDiff(from, to)` | branches · diff between two refs |

#### `p.changeRequests` — lifecycle and merge

A change request (CR) is how a session's work merges into the default branch.

| method | what |
| --- | --- |
| `list()` · `get(crId)` | open CRs · one CR |
| `diff(crId)` · `mergePreview(crId)` | its diff · preview the merge result |
| `open(input)` · `merge(crId, input?)` | open · merge a CR |
| `close(crId, input?)` · `reopen(crId, input?)` | close without merging · reopen a closed one |
| `requestChanges(crId, input)` | record feedback, optionally delivered back to the originating session |

#### `p.sessions` — and the session handle

| method | what |
| --- | --- |
| `list()` | the project's sessions |
| `create(input?)` | create a session |
| `session(sid)` | the session handle (same as `beeuni.session(id, sid)`) |

The session handle is the heart of the runtime — see
[Sessions](/docs/sdk/sessions).

`create(input)` accepts `connector_bindings` keyed by connector-connection slug.
Each value names a `connection_id`. It also accepts `secrets` for
backend-origin secret narrowing. `require_connectors` is deprecated and
inert: the field is still accepted (an old client is never rejected for
sending it), but nothing reads it — a session is never refused at create time
for a connector with no usable account. See [Accounts and the call-time
gate](#accounts-and-the-call-time-gate).

#### `p.approvals` — the connector approval inbox

Pending connector-gated actions awaiting a decision — backs the
permission-approval UX (`APPROVE` / `ASK` / `BLOCK`).

| method | what |
| --- | --- |
| `list(options?)` · `sessionsNeedingInput(options?)` | pending approvals · sessions blocked on a decision |
| `resolve(executionId, decision, scope?)` | approve or deny one — `decision: 'approve' \| 'deny'`, `scope: 'once' \| 'session' \| 'session_all'` |

#### `p.gateway` — LLM observability

Request logs, cost/latency rollups, budgets, and gateway API keys for this
project's model traffic.

| method | what |
| --- | --- |
| `logs(opts?)` · `log(logId)` | request log entries · one log entry |
| `overview(days?)` · `series(days?)` · `breakdown(days?)` · `sessions(days?)` · `errors(days?)` | rollups, per-session cost, and errors over a window |
| `budgets()` · `setBudget(input)` · `deleteBudget(budgetId)` | read · create/edit · remove a budget |
| `keys()` · `createKey(name)` · `revokeKey(keyId)` | list · mint · revoke a gateway API key |
| `playground(prompt, models)` | run one prompt against up to 6 models |
| `routing.get()` · `routing.set(policy)` · `routing.reset()` | read · replace · inherit the routing policy |
| `routing.preview(input)` | resolve a route without invoking a model |

A routing policy holds a default model, a vision model, and an ordered
fallback chain, each model attempted at most once; `fallbackOn` is
`transient` or `any-error`.

The gateway moves to the next fallback model only when the current model
failed before it returned any output:

- `transient`: a network error, a timeout, any `5xx`, or `402`, `403`, `429`.
- `any-error`: every failure, including a `400`.

A failure after output started stays with that model. A request on your own
provider key (BYOK) never falls back to a Beeuni-billed model. Each fallback
model gets its own generation defaults.

#### `p.channels` — Slack / email / voice

Connector surfaces that let an agent act as a Slack app, an email address,
or join a realtime voice call.

| method | what |
| --- | --- |
| `slack.installation()` · `slack.mode()` · `slack.manifest()` | current install · mode · app manifest |
| `slack.connect(input)` · `slack.disconnect()` | connect · disconnect |
| `slack.getFile(url)` · `slack.uploadFile(input)` | download · upload a file via the server proxy |
| `email.installation(connectorSlug?)` · `email.mode()` | current install · mode |
| `email.connect(input)` · `email.disconnect(...)` · `email.updatePolicy(input)` | connect · disconnect · update the send/reply policy |
| `voice.setBotName(name)` | rename the bot in a live call |

#### `p.modelDefaults` — default model preferences

Account, agent, and project-scoped model defaults, resolved by the gateway.

| method | what |
| --- | --- |
| `get()` · `set(input)` · `clear(params)` | read · set a default · clear an override |

#### `p.setDefaultAgent` — project default agent

`p.setDefaultAgent(agentName)` checks that the agent is declared and enabled,
then sets it as `default_agent` in the project's `beeuni.yaml`. New sessions
prefer this agent unless a user picks another one.

#### `p.updateFeatureFlag` — feature flags

`p.updateFeatureFlag(feature, enabled)` turns one feature flag on or off for the
project. Pass `enabled: null` to clear the override and fall back to the
platform default. It calls the canonical `PATCH /v1/projects/:id/features`.

`feature` is one of `FEATURE_FLAG_KEYS` (exported from `@beeuni/sdk`, typed as
`FeatureFlagKey`). The caller needs the project's `project.settings.write`
permission; the route answers `403` otherwise.

Every flag-gated route rejects the same way while the flag is off: HTTP `403`
with `{ error, code: "feature_disabled", feature }`. Use `isFeatureDisabledError(error)`
to branch on it and `featureDisabledKey(error)` to read the flag key — never
match on the message text.

`p.updateExperimentalFeature(feature, enabled)` is the **deprecated** alias. It
keeps calling the deprecated route alias `PATCH /v1/projects/:id/experimental`
so consumers pinned to an older deployed API keep working. Use
`p.updateFeatureFlag` in new code.

#### `p.sandbox` — templates and snapshot builds

Sandbox build config beyond `sandboxHealth`/`sandboxTemplates` on the project
handle: Dockerfile/image/warm-pool templates and their snapshot builds.

| method | what |
| --- | --- |
| `list()` · `snapshots()` | sandboxes for this project · built snapshots |
| `rebuildSnapshot(slug?)` · `fixWithAgent()` | rebuild a snapshot · ask an agent to fix a broken build |
| `createTemplate(input)` · `updateTemplate(...)` · `removeTemplate(...)` · `buildTemplate(...)` | add · edit · delete · build a template |
| `setProvider(provider)` | request a provider switch. `null` (or the platform default / the already-active provider) applies immediately; switching to a different enabled provider starts a durable prepare→verify→activate transition — the current provider keeps serving while the target warm image is built and verified, then activated. The return is a tagged union: `kind:'project'` (immediate) or `kind:'preparation'` (poll `getProjectSandboxProviderTransition()` until `activated`/`failed`) |

### Platform admin hooks (`@beeuni/sdk/react`)

These hooks serve the admin console. The API answers them only for a platform
admin. Each mutation invalidates the `['admin', 'accounts']` queries.

| Hook | What it does |
| --- | --- |
| `useAdminRole(userId, options?)` | read the caller's role from `GET /user-roles` → `{ isAdmin, role }`. `userId` keys the cache. A missing `userId` disables the query. Refetches on mount and focus, so a revoked role stops gating within one navigation. |
| `useAdminGrantCredits()` | mutation `{ accountId, amount, description, isExpiring }`: add wallet credit. `POST /admin/api/accounts/:id/credits`. Moves money: confirm in the UI first. |
| `useAdminDebitCredits()` | mutation `{ accountId, amount, description }`: remove wallet credit. `POST /admin/api/accounts/:id/credits/debit`. Moves money: confirm in the UI first. |
| `useAdminSetTier()` | mutation `{ accountId, tier }`: set the plan tier. |
| `useAdminGrantTrial()` | mutation `{ accountId, tierKey, seats, durationDays, creditGrant?, note? }`: grant or replace a trial. The account behaves as `tierKey` until the window ends. `creditGrant` also adds wallet credit. |
| `useAdminRevokeTrial()` | mutation `{ accountId }`: end an active trial now. Answers `400` when none is active. |

### Escape hatch

`beeuni.runtime()` returns the typed runtime REST client for the active
sandbox.
On a client created by `createScopedBeeuni` (`@beeuni/sdk/server`), it **throws**
— the process-global "active" runtime is another request's sandbox in a
multi-tenant server, a cross-tenant leak. Use the session-scoped
`beeuni.session(pid, sid).runtime` (call `ensureReady()` first) instead, which
resolves that session's own sandbox.

## Modules

The framework-free modules behind [the client](#the-client) facade and the
React hooks. Reach for them when you need one operation without the facade,
a pure helper, or a Node-only isolation layer. Each module carries a
stability tier so you know what to build on.

| Tier | Meaning |
| --- | --- |
| Canonical | Import from the root `@beeuni/sdk`. Use this for all new code. |
| Supported | A dedicated subpath (`@beeuni/sdk/react`, `@beeuni/sdk/server`). First-class, not deprecated. |
| Deprecated alias | An old subpath that still works. It re-exports code the root already exports. Import from root instead. |
| Internal | Outside semver. Do not import this in host code. |

The root entry is canonical. Every framework-free name below is importable
straight from `@beeuni/sdk`:

```ts
import { files, getSessionHealth, getClient, authenticatedFetch, backendApi } from '@beeuni/sdk';
```

### Canonical modules

| Module | What it does |
| --- | --- |
| Files | Workspace file operations: list, read, search, write |
| Session runtime | Health probe and preview/proxy URL builders |
| Runtime client | The typed runtime REST client and its full type surface |
| Auth | `authenticatedFetch` and token accessors |
| Projects REST | The raw REST functions the facade wraps |
| API client | `backendApi`, the low-level typed HTTP client |
| Turns | Message-to-turn grouping, cost, and status math — see [Turns](#turns) |
| Transcripts | `formatTranscript`, a client-side Markdown export |
| Composer lists | The session composer's agent and model lists: which to offer, which runs |

#### Files

```ts
import { files } from '@beeuni/sdk';

const tree = await files.list('/workspace/src');
const { content } = await files.read('/workspace/README.md');
const hits = await files.findText('TODO');
await files.upload(file, '/workspace/uploads', file.name, {
  onProgress: ({ loadedBytes, totalBytes }) => {
    console.log(`${loadedBytes}/${totalBytes}`);
  },
});
```

Uploads larger than 64 KiB cross the sandbox edge in verified chunks. The
progress callback runs after each chunk reaches the session filesystem.

`uploadNativeFile({ uri, name, type? }, targetPath, baseUrl)` uploads a device
file from React Native in one request. The runtime streams the bytes from
`uri`, so they never enter the JS heap. It uses the same auth, deadline and
`401` replay as `files.upload`. It does not chunk.

`fetchProjectArchive`, `fetchCostExportCsv` and the sandbox version calls send
through the same transport. A failure throws an `ApiError` with `status`.

`files.write(path, blob)` replaces the file at `path` and fails if `path` is a
folder. `files.create(path)` creates an empty file and never overwrites: when a
file or folder already exists at `path`, it throws `FileExistsError`
(`status` `409`, `code` `'FILE_EXISTS'`).

```ts
import { FileExistsError, files } from '@beeuni/sdk';

try {
  await files.create('/workspace/notes.md');
} catch (error) {
  if (error instanceof FileExistsError) console.log(`${error.path} already exists`);
  else throw error;
}
```

`files` targets the globally active sandbox. If your host runs more than one
session at a time, call `s.files` on the session handle instead. It always
targets that session's own sandbox. See [Sessions](/docs/sdk/sessions).

#### Session runtime helpers

```ts
import { getSessionHealth, isRuntimeReady } from '@beeuni/sdk';

const result = await getSessionHealth();
if (result.ok && isRuntimeReady(result.health)) {
  // the sandbox daemon is ready
}
```

`getSessionHealth` never throws on a non-2xx status. It returns
`{ status, ok, health, body }` and lets you decide what a status means. The
same module exports the URL helpers that rewrite an agent's `localhost` output
into a reachable proxy URL: `detectLocalhostUrls`, `rewriteLocalhostUrl`,
`proxyLocalhostUrl`, `parseLocalhostUrl`, and `buildWebProxyUrl`.

#### Runtime client

```ts
import { getClient } from '@beeuni/sdk';

const client = getClient();
const { data } = await client.session.list({ limit: 100 });
```

`getClient()` returns the typed runtime compatibility client for the active sandbox,
with auth already injected. Prefer `beeuni.session(pid, sid).runtime`, the
same client scoped to one session, over the global `getClient()` when your
host runs more than one session.

#### Auth helpers

```ts
import { authenticatedFetch, getAuthToken } from '@beeuni/sdk';

const res = await authenticatedFetch(`${runtimeUrl}/beeuni/health`);
const token = await getAuthToken();
```

The token comes from the `getToken` function you passed to `createBeeuni`.
Most app code does not need this module — the file, session, and facade
layers already authenticate for you.

#### API client

```ts
import { backendApi } from '@beeuni/sdk';

const data = await backendApi.get('/some/endpoint');
await backendApi.post('/some/endpoint', { name: 'x' });
```

`backendApi` is the typed HTTP client every REST function builds on. Use it
only for an endpoint that has no typed wrapper yet.

#### Composer agent and model lists

Build a session composer's agent picker and model picker with the same rules
the Beeuni web app uses. Every function is pure: fetch the inputs with the
client, then pass them in.

```ts
import {
  composerSelectableAgents,
  createModelVisibility,
  flattenModels,
  modelInDefaultView,
  pickerProviderList,
  projectConfigAgentsToRuntimeAgents,
  resolveComposerAgent,
  resolveComposerModel,
  resolveModelDefault,
} from '@beeuni/sdk';

// Agents: the roster, the picker list, and the agent the send carries.
const roster = projectConfigAgentsToRuntimeAgents(detail.config);
const pickerAgents = composerSelectableAgents(roster, { enableProjects: false });
const agent = resolveComposerAgent({
  agents: roster,
  boundAgent: session?.agent,
  defaultAgent: detail.config.default_agent,
  selectedAgent: pick,
});
if (agent.disabled) {
  // no accessible agent: refuse the send
}

// Models: gateway projects read /model-picker, native projects merge the
// catalog synthesis with the running sandbox's provider list.
const gatewayEnabled = detail.project.experimental?.llm_gateway === true;
const providers = pickerProviderList({
  gatewayEnabled,
  modelPicker,
  runtimeProviders,
  llmCatalogProviders,
  secretNames,
});
const models = flattenModels(providers, { providerMode: gatewayEnabled ? 'gateway' : 'native' });
const isVisible = createModelVisibility({ catalogModels: models });
const defaultView = models.filter(
  (m) => m.enabled !== false && modelInDefaultView(m, { search: '', isStoreVisible: isVisible, selected }),
);
const { model, explicit } = resolveComposerModel({
  models,
  picks: [sessionPick],
  serverDefault: resolveModelDefault(modelDefaults, agent.selected ?? undefined),
  providers,
});
```

`resolveComposerModel` returns `explicit: undefined` when the composer is on
its default. `model` is the key to display and send. Each flattened model's
`id` is the ref a pick stores; `modelRefToKey(ref, gatewayEnabled)` reads a
stored ref (a session pin, a channel binding, a trigger's `model`) back into a
key.

### Supported subpaths

| Subpath | What it does |
| --- | --- |
| `@beeuni/sdk/react` | React hooks — see [React hooks](/docs/sdk/react) |
| `@beeuni/sdk/server` | Request-scoped config for multi-tenant backends |
| `@beeuni/sdk/wire-message-id` | The runtime wire message-id clock alone (one module, no imports). The root exports the same names |

#### Wire message ids

A prompt's `messageId` is its position in the transcript. The runtime orders
messages by `msg_` + a 48-bit clock (`Date.now() * 0x1000`, low 48 bits) +
14 base62 characters, and the clock wraps every 2^36 ms (about 2.2 years).
Never encode it by hand.

```ts
import { mintWireMessageId } from '@beeuni/sdk';

// `after`: the ids already in this session's transcript.
const messageId = mintWireMessageId({ after: transcriptIds });
```

The mint is dated two minutes back, then lifted one tick above the newest id
in `after` that is at most one hour ahead. A caller that holds the newest
position as a clock instead of ids uses `mintWireMessageIdAbove({ nowMs,
newestKnownTime })`, which returns `{ id, time }`. `newestWireIdClock(ids)`
finds that clock. Every comparison is on the ring, so ids on both sides of a
wrap keep their true order.

The 48-bit clock wraps every ~2.2 years, so never compare two clocks with `>`
or `<=`: an id minted just after a wrap is numerically smaller than one minted
just before it. Use `wireIdClockDelta(clock, reference)`, the signed distance
on the ring (positive when `clock` is ahead), and `maxWireIdClock(clocks)` for
the newest of several:

```ts
import { maxWireIdClock, wireIdClockDelta } from '@beeuni/sdk/wire-message-id';

const floor = maxWireIdClock([transcriptNewest, deliveredNewest]);
const stale = floor !== null && wireIdClockDelta(clientClock, floor) <= 0n;
```

#### Server-side isolation

`createBeeuni` stores its config, including the token function, in one
process-wide variable. That is fine for a browser tab, a CLI, or a
single-tenant server. It is unsafe for a Node server that handles concurrent
requests for different users, because the last `createBeeuni` call wins for
every in-flight request. `@beeuni/sdk/server` fixes this with per-request
isolation:

```ts
import { createScopedBeeuni } from '@beeuni/sdk/server';

export async function handler(req: Request) {
  const beeuni = createScopedBeeuni({ backendUrl, getToken: () => tokenFor(req) });
  return beeuni.projects.list();
}
```

`createScopedBeeuni` and `runWithBeeuni` isolate config per request with
Node's `AsyncLocalStorage`. Never import `@beeuni/sdk/server` from a browser
bundle — it statically pulls in `node:async_hooks`.

A scoped client's top-level `runtime()` throws (it would resolve another
tenant's sandbox). Reach a specific session's runtime via
`beeuni.session(pid, sid).runtime` after `await s.ensureReady()`.

### Deprecated aliases

About twenty old subpaths still work: `/files`, `/turns`, `/session`, `/auth`,
`/projects-client`, `/api-client`, `/config`, `/event-stream`,
`/opencode-client`, `/platform-client`, and more. Each one re-exports code the
root `@beeuni/sdk` entry already exports. They stay working so no existing
import breaks, but new code should import from the root.

### Internal modules

`@beeuni/sdk/internal/*` holds the zustand stores apps/web's own runtime uses
internally — session sync state, active-runtime tracking, and reconnect
bookkeeping. This subpath is explicitly outside semver. Do not import it in
host code; the [React hooks](/docs/sdk/react) already expose the state you
need.

## Turns

Plain functions that group session messages into turns and classify each
message part. Use them to build a custom chat renderer instead of the
reference one in `apps/web`. No React, no DOM — every export is a plain
function or type, safe to call from any host.

```ts
import { classifyPart, classifyTurn, toolInfo, toolViewModel } from '@beeuni/sdk';
```

Import every function from the root `@beeuni/sdk` entry. The
`@beeuni/sdk/turns` subpath still works, but it is a deprecated alias. New
code must use the root entry — see [Distribution](#distribution).

### Classify a part

`classifyPart(part)` normalizes one of the 12 part types of the Beeuni
transcript format (`beeuni.transcript.v1`, `BEEUNI_TRANSCRIPT_SCHEMA`) into a
`ClassifiedPart` — a union keyed by `kind`. Each variant already resolves the
fields a renderer needs: tool status, parsed JSON output, image detection.

```ts
import { classifyPart, type ClassifiedPart } from '@beeuni/sdk';

for (const part of message.parts) {
  const classified: ClassifiedPart = classifyPart(part);
  switch (classified.kind) {
    case 'text':
      render(classified.text);
      break;
    case 'tool':
      render(classified.tool.title, classified.tool.status);
      break;
  }
}
```

| `kind` | shape |
| --- | --- |
| `text` | `{ id, text, synthetic }` — skip `synthetic` parts; they mark shell mode's synthetic prompt |
| `reasoning` | `{ id, text }` |
| `tool` | `{ id, tool: ToolView }` |
| `file` | `{ id, filename?, mime, url, isImage, isPdf }` |
| `subtask` | `{ id, description, agent, prompt, model? }` |
| `patch` | `{ id, hash, files, fileCount }` |
| `snapshot` | `{ id, snapshot }` |
| `agent` | `{ id, name }` |
| `retry` | `{ id, attempt, message, createdAt }` |
| `compaction` | `{ id, auto, overflow, tailStartId? }` |
| `step` | `{ id, phase: 'start' \| 'finish', snapshot?, reason?, cost?, tokens? }` |
| `unknown` | `{ raw }` — a part type this SDK version does not know |

An unrecognized wire part degrades to `unknown` at runtime instead of
throwing. This lets an older client talk to a newer server.

A tool part classifies into `ToolView`:

```ts
interface ToolView {
  name: string;
  title: string;
  status: 'pending' | 'running' | 'done' | 'error';
  input?: Record<string, unknown>;
  output?: string;
  error?: string;
  outputParsed?: unknown; // JSON.parse(output) when it parses, capped at 256KB
  outputText?: string;    // the raw output text, always present
  kind?: ToolKind;        // toolKind(name): the action, whatever the harness calls the tool
  files?: ToolFile[];     // the files a file tool read, wrote or changed: path, patch, additions/deletions
  diff?: string;          // a unified diff of the change
  diagnostics?: Record<string, unknown[]>; // language-server diagnostics, keyed by file path
  answers?: string[][];   // the answers to a `question`, one list per question
  childSessionId?: string; // the child session a delegating call (`task`) runs
}
```

`toToolView(part)` builds one straight from a tool part. Read these fields
instead of a harness's own `state.metadata`: the same card then renders a
runtime `edit` (`filePath`, `filediff`) and a pi `edit` (`path`, `edits`,
a unified `patch`).

Some tools (`web_search`, `image_search`, connector calls) report
`state.status: 'completed'` even when their JSON body carries
`success: false`. `classifyPart` detects this and sets `ToolView.status` to
`'error'` in that case too.

`classifyTurn(message)` classifies every part of one assistant message and
returns a `ClassifiedTurn` with three fields:

- `parts` — each part, classified
- `error` — from `message.info.error`, if any
- `isEmpty` — true when the turn has no error and no part with visible content

### Tool metadata

`toolKind(name)` is the one tool taxonomy: it maps any harness's tool name
(pi's `write`, runtime's `oc-apply_patch`, a plugin's `pty_spawn`) to a
`ToolKind` — `read`, `edit`, `write`, `bash`, `web_search`, `task`,
`question`, … or `other`. Narration families, view models, `toolInfo`
categories and the context-tool group all derive from it. `inputPath(input)`
reads a file tool's path from `filePath`, `file_path` or `path`.

```ts
toolKind('oc-apply_patch'); // 'apply_patch'
toolKind('pty_spawn');      // 'pty'
inputPath({ path: 'app.py' }); // 'app.py'
```

Two more lookups both describe a tool. Do not confuse them.

- `toolInfo(name)` — icon-free, returns `{ label, category }`. `classifyPart`
  uses this internally. `ToolCategory` is `'shell' | 'files' | 'search' |
  'edit' | 'web' | 'task' | 'other'`.
- `getToolInfo(name, input)` — icon-aware, returns `{ icon, title, subtitle }`
  for the reference tool-card UI. The subtitle comes from the tool's input,
  for example a file path or a search query.

```ts
toolInfo('bash'); // { label: 'Shell', category: 'shell' }
getToolInfo('write', { filePath: '/workspace/main.go' });
// { icon: 'file-pen', title: 'Write', subtitle: 'main.go /workspace' }
```

Both functions match tool-name prefixes, so Beeuni's plugin tool families
(`agent_*`, `session_*`, `task_*`, `trigger_*`, `project_*`, `pty_*`) resolve
without a registry update. An unknown tool name never throws — it falls back
to `humanizeToolName(name)` with category `'other'`.

### Tool view models

`toolViewModel(classifiedTool)` maps a classified tool part to a shape built
for one tool family. A UI can then render it specially instead of as a
generic JSON blob.

```ts
const vm = toolViewModel(classifiedTool);
if (vm.kind === 'shell') {
  render(vm.command, vm.stdout, vm.exitCode);
}
```

| `kind` | shape | tools |
| --- | --- | --- |
| `web-search` | `{ query, results?, answer?, error? }` | `web_search`, `image_search` |
| `shell` | `{ command, stdout?, exitCode? }` | `bash` |
| `file-read` | `{ path, preview? }` | `read` |
| `file-write` | `{ path, preview? }` | `write` |
| `file-edit` | `{ path, diff?: DiffLine[] }` | `edit`, `morph_edit` |
| `search` | `{ pattern, matches?: SearchMatch[] }` | `grep`, `glob` |
| `task` | `{ description, agent? }` | `task` |
| `todo` | `{ items: TodoItem[] }` | `todowrite` |
| `question` | `{ questions: QuestionItem[], answers? }` | `question`, `ask` |
| `generic` | `{ label, inputPretty?, outputPretty? }` | everything else, always safe to render |

`DiffLine` is `{ type: 'added' | 'removed' | 'unchanged', text }`. String
fields are capped so one large tool output never breaks a render: 4000
characters for pretty-printed JSON, 256KB before a diff runs.

### Group messages into turns

A turn pairs one user message with the assistant messages that answered it.
It is the unit a chat UI renders as one exchange.

```ts
import { groupMessagesIntoTurns, collectTurnParts, type TurnLike } from '@beeuni/sdk';

const turns: TurnLike[] = groupMessagesIntoTurns(messages);
for (const turn of turns) {
  const parts = collectTurnParts(turn);
}
```

`groupMessagesIntoTurns` links each assistant message to its parent user
message. It falls back to message order when a parent link is missing. It
also attaches an orphan assistant message — one with no parent that precedes
every user message — to the first turn, not the last.

Related helpers:

- `findLastTextPart(parts)` — the turn's final response text
- `turnHasSteps(parts)` — true if a `tool`, `compaction`, `snapshot`, or `patch` part exists
- `isShellMode(turn)` / `getShellModePart(turn)` — a turn that is one synthetic prompt driving one `bash` call

### Type guards

Narrow a part by `type`:

```ts
import { isTextPart, isToolPart, getPartText } from '@beeuni/sdk';

if (isTextPart(part)) {
  // part.type narrowed to 'text'
}
const text = getPartText(part); // works for 'text' and 'reasoning' parts
```

Also available: `isReasoningPart`, `isFilePart`, `isAgentPart`,
`isCompactionPart`, `isSnapshotPart`, `isPatchPart`, `isStepPart`
(`step-start` and `step-finish`).

### Status and errors

```ts
import { getWorkingState, getTurnStatus, formatDuration } from '@beeuni/sdk';

const status = getTurnStatus(parts, childMessages); // "Running commands..."
formatDuration(4300); // "4s" — durations under 1s return ''
```

`getTurnStatus` scans a turn's parts for the last status line. When the last
part is a running `task` delegation, pass `childMessages` so the status shows
the sub-agent's real activity instead of a generic "Delegating..." line.

```ts
import { getTurnError, getChildSessionError, unwrapError } from '@beeuni/sdk';

getTurnError(turn);                    // the first assistant error, unwrapped
getChildSessionError(childMessages);   // newest error in a sub-agent's messages
unwrapError(rawError);                 // normalizes double-JSON and mixed error shapes
```

### Cost and token totals

```ts
import { getTurnCost, getSessionCost, formatCost, formatTokens } from '@beeuni/sdk';

const info = getTurnCost(partsWithMessage, modelPricingLookup);
const sessionCost = getSessionCost(messages, modelPricingLookup);

formatCost(0.0032);  // "$0.003"
formatTokens(12345); // "12k"
```

Both functions read cost and token totals from `step-finish` parts. When a
part reports zero, they estimate cost from token counts using a
`ModelPricingLookup`.

ChatGPT/Codex subscription messages contribute `$0.00`, including historical
messages with nonzero runtime costs. Token and context usage remain visible.
Mixed sessions retain charges for paid API messages. OpenAI API models remain
billable, including API models whose names contain `codex`.

### Child sessions and pending requests

A project session's conversation tree (the root and the subagent
conversations it spawned) comes from its `runtime_sessions` list. These
helpers read it, preferring the current names over the pre-W4 ones:

```ts
import { directSubsessions, projectSessionForRuntimeId, rootRuntimeSession, runtimeSessionsOf } from '@beeuni/sdk';

const root = rootRuntimeSession(projectSession);            // the root conversation id
const children = directSubsessions(projectSession);         // its subagent conversations
const owner = projectSessionForRuntimeId(projectSessions, childId); // the session a conversation belongs to
```

A `task` tool call delegates to a child session. These helpers connect a
parent turn to that child's own messages.

```ts
import { getChildSessionId, getChildSessionToolParts } from '@beeuni/sdk';

const childId = getChildSessionId(taskToolPart);
const steps = getChildSessionToolParts(childMessages);
```

Match a pending permission or question request to its tool call, and find
which tool parts to hide while one is active:

```ts
import { getPermissionForTool, getHiddenToolParts, isToolPartHidden } from '@beeuni/sdk';

const permission = getPermissionForTool(permissions, callID);
const hidden = getHiddenToolParts(activePermission, activeQuestion);
```

### Formatting and lists

```ts
import { getFilename, getDirectory, relativizePath, stripAnsi } from '@beeuni/sdk';

getFilename('/workspace/src/main.go');               // "main.go"
getDirectory('/workspace/src/main.go');               // "/workspace/src"
relativizePath('/workspace/src/main.go', '/workspace'); // "src/main.go"
```

Session-list helpers operate on the same session data:

```ts
import { sortSessions, childMapByParent, allDescendantIds } from '@beeuni/sdk';

sessions.sort(sortSessions(Date.now())); // pins sessions updated in the last 60s
const childMap = childMapByParent(sessions);
const descendants = allDescendantIds(childMap, sessionId);
```

Retry state: `getRetryInfo(sessionStatus)` returns `{ attempt, message, next,
details? }` when `status.type === 'retry'`, with `message` capped to 60
characters. `details` preserves a structured LLM-gateway envelope when present:
the final provider, gateway code, request ID, suggestion, upstream status, and
ordered candidate failures. Each candidate failure contains `attempt`,
`provider`, `routeModel`, `resolvedModel`, `stage`, optional `status`, `code`,
and `message`. Legacy plain-text retries return `details: undefined`.
`getRetryMessage(sessionStatus)` returns the full unwrapped message. When
the runtime retains only that message, the gateway composite still includes the
request ID and each candidate's provider, resolved model, HTTP status, code,
and bounded message.

### Structural types

The grouping and status functions accept minimal structural types —
`PartLike`, `MessageInfoLike`, `TurnLike`, `ToolStateLike`,
`SessionStatusLike` — instead of the concrete transcript types.
Your own message and part shapes flow through unchanged as long as they match
the required fields. `classifyPart` and `classifyTurn` are the exception.
They type against the Beeuni `Part` union (`beeuni.transcript.v1`), so their
exhaustiveness check catches a new part type at build time.

## Distribution

```sh
npm install @beeuni/sdk
```

The package ships as compiled ESM with full TypeScript type declarations.
`react` and `@tanstack/react-query` are optional peer dependencies. If you
use `@beeuni/sdk/react`, install both.

```ts
import { createBeeuni } from '@beeuni/sdk'; // framework-free core
import { useSession } from '@beeuni/sdk/react'; // optional React layer
import { createScopedBeeuni } from '@beeuni/sdk/server'; // Node and Bun servers
```

### Entry points and stability

The root entry, `@beeuni/sdk`, is canonical. It exports the full
framework-free surface and runs in browsers, Node 18+, Bun, and edge
runtimes.

| Entry | Tier | Contract |
| --- | --- | --- |
| `@beeuni/sdk` | Canonical | Framework-free. Never imports React or `node:*`. |
| `@beeuni/sdk/react` | Supported | React hooks. The only entry that imports React. |
| `@beeuni/sdk/server` | Supported | Per-request config isolation for Node and Bun servers, via `node:async_hooks`. Never bundle it into a browser. |
| Legacy subpaths (`/projects-client`, `/turns`, `/files`, and more) | Deprecated | Still work. Each re-exports from the root. Import from the root instead. |
| `@beeuni/sdk/internal/*` | Internal | Used by the Beeuni web app only. Not a supported API. |

### CDN bundles

The package also ships two browser bundles, built by `tsup`: an ESM bundle
(`dist/beeuni.esm.min.js`) and an IIFE global (`dist/beeuni.global.js`) that
defines `window.Beeuni`.

```html
<script src="https://unpkg.com/@beeuni/sdk/dist/beeuni.global.js"></script>
<script>
  const beeuni = Beeuni.createBeeuni({
    backendUrl: 'https://api.beeuni.ai/v1',
    getToken: async () => KEY,
  });
</script>
```

`window.Beeuni` exposes the same root entry as the npm import:
`Beeuni.createBeeuni`, `Beeuni.classifyTurn`, `Beeuni.ApiError`.

## See also

- [SDK](/docs/sdk) — install and your first session.
- [Sessions](/docs/sdk/sessions) — lifecycle, streaming, and error handling.
- [React hooks](/docs/sdk/react) — the reactive layer built on these modules.


### Agent repository access

Agent configuration accepts `repository_access?: boolean` (default `true`).
Set `false` to run new sessions without the project repository or repository API access.
Git, secret, connector, and tool permissions remain separate. Existing sessions retain their saved policy.
`AgentConfigBlock.workspace` is deprecated. The SDK maps legacy `branch`/`runtime` to the boolean field.
A legacy `read` write requires an explicit `repository_access` choice; it does not enable read-only repository access.


## Provider and model access

Read `beeuni.projects.modelAccess(projectId)`. Change one target with `beeuni.projects.setModelAccess(projectId, { target: 'provider' | 'model', id, enabled })`.

Use provider ID `beeuni` for Beeuni Managed Models, or a catalog provider ID for BYOK, Codex, and custom providers. Use the full gateway wire ID for a model. Provider disable covers future models and preserves individual model preferences and saved credentials.

The current default model and its provider require a replacement before disabling (`409 cannot_disable_default`). Direct gateway requests to disabled targets fail with `model_disabled` or `provider_disabled`. Existing in-flight upstream requests finish normally. The response's `enforced` flag is false when the project uses a native runtime model connection instead of the gateway.

React consumers use `useModelAccess(projectId)` from `@beeuni/sdk/react`. Its `setEnabled(change)` mutation refreshes the management catalog and session picker.
