Skip to content
BeeuniBeeuni
English
Esc
↑↓navigate↵open⌘Jpreview
On this page

Sessions

Run a session, stream its events, and handle the errors it can throw.

A session is one agent run, in its own sandbox, on its own git branch. beeuni.session(projectId, sessionId) returns the handle for everything a session does: start it, send prompts, stream events, and read status. This page covers the handle, the readiness handshake, streaming, and the typed errors an SDK call can throw.

const s = beeuni.session(projectId, sessionId);

s is the handle for everything a session does. The session ID, the sandbox ID, and the branch name are the same value. See Sessions for the concept.

Session lifecycle

Method Wraps What it does
s.get(opts?) GET /projects/:pid/sessions/:sid Reads session details
s.update(input) PATCH …/sessions/:sid Renames the session, replaces its labels, or merges metadata keys
s.start(waitMs?) POST …/sessions/:sid/start Provisions and boots the runtime
s.restart() POST …/sessions/:sid/restart Restarts the runtime; keeps the same sandbox
s.configState() GET …/sessions/:sid/config Reads config freshness (stale) and the config release state (release)
s.reloadConfig(input?) POST …/sessions/:sid/reload Recompiles agent config and replaces the runtime after validation
s.reloadConfigStream(input, onEvent) POST …/sessions/:sid/reload-stream Runs the same reload and emits server-confirmed progress phases
s.stop() POST …/sessions/:sid/stop Stops the runtime; the session stays
s.delete() DELETE …/sessions/:sid Deletes the session
s.setSharing(intent) PUT …/sharing Sets sharing and visibility
s.participants() GET …/sessions/:sid/participants Reads who can open the session and who sent each prompt
s.watch() GET …/sessions/:sid/watch Reads whether you get this session’s notifications. Needs notification_center; 403 feature_disabled while it is off
s.setWatch(watching) PUT …/sessions/:sid/watch Follows (true) or mutes (false) the session’s notifications for you. Needs notification_center; 403 feature_disabled while it is off
s.presence({ tab_id, active, alerts? }, { keepalive? }) PUT …/sessions/:sid/presence Reports an open tab. alerts: true says the tab shows its own notifications, so the phone push is held back. With notification_center off, any present tab holds it back. keepalive lets the request outlive a closing page; default on for an absent report
s.cost() GET /usage/session-costs/:sid Reads finalized LLM and compute cost without starting the runtime
s.scope() GET …/sessions/:sid/scope Reads stored secret narrowing and materialized connection bindings
s.rescope(input) PUT …/sessions/:sid/scope Replaces supplied scope fields for the next prompt or tool call
s.commit(input?) — Commits the agent’s work
s.providerSecretPool.get(providerId) GET …/provider-secret-pools/:providerId Reads selected account secret IDs
s.providerSecretPool.list() GET …/provider-secret-pools Lists configured pools, including empty pools, and reports can_edit
s.providerSecretPool.set(providerId, secretIds) PUT …/provider-secret-pools/:providerId Replaces the selection; null restores the project default

Labels and metadata

Every session carries labels: string[] and a metadata object. Use them to classify sessions and to decide how your app renders one.

  • Labels are free-form: each is trimmed, 1–64 characters, at most 20 per session, duplicates dropped. Matching is exact and case-sensitive.
  • Metadata is a free-form JSON object for your own keys, at most 16,384 characters of JSON per write. The server also stores its own keys there (custom_name, source, …); a request that names a server-managed key is refused with 400. Prefix your keys (app_…) to stay clear of them.
// Set both at create.
const project = beeuni.project(projectId)
const created = await project.sessions.create({
  initial_prompt: 'Triage ticket T-142',
  labels: ['support', 'tier:gold'],
  metadata: { app_ticket: 'T-142', app_priority: 2 },
})

// Update later. `labels` replaces the list; `metadata` merges keys and a
// `null` value removes that key.
const s = beeuni.session(projectId, created.session_id)
await s.update({ labels: ['support', 'resolved'], metadata: { app_priority: null } })

// List by label, server-side: a session must carry EVERY given label.
const { items } = await project.sessions.listPage({ labels: ['support', 'resolved'] })

// Render conditionally.
for (const session of items) {
  const badge = session.labels?.includes('tier:gold') ? 'Gold' : null
  const ticket = session.metadata.app_ticket as string | undefined
}

In React, pass labels to useProjectSessions(projectId, { labels }); each label set gets its own cache entry. Agents set the same fields from the CLI (beeuni sessions new --label … --meta k=v, beeuni sessions update --label …) and from the hosted MCP server (start_session labels / metadata, list_sessions labels). A runnable version is packages/sdk/examples/12-session-labels.ts.

With pooled_provider_secrets and llm_gateway enabled, a session can select several available account secrets for one provider. Create a project-scoped resource through beeuni.accounts.secretResources.create(accountId, { project_id: projectId, ...input }). Every project member can use it by default. Restrict it with .setAccess(accountId, secretId, 'members', userIds). Reads return metadata and grants, never the secret value. After a provider 429 before output starts, the gateway tries another selected key.

await s.providerSecretPool.set('anthropic', [primarySecretId, backupSecretId]);
const selected = await s.providerSecretPool.get('anthropic');
await s.providerSecretPool.set('anthropic', null); // inherit project default

Use the streamed method when the caller displays reload progress:

await s.reloadConfigStream({ refresh_repo: false }, (event) => {
  if (event.type === 'phase') console.log(event.phase);
});

The phases are checking-session, refreshing-workspace, compiling-config, applying-config, and confirming-config. The server omits refreshing-workspace when refresh_repo is false. The applying-config phase includes the daemon’s validated runtime replacement.

s.configState() returns release when the API supports config releases. Its type is SessionConfigRelease. The reload results carry the same object.

const { stale, release } = await s.configState();
if (release?.fallback_reason) {
  // The desired config failed on the box. An earlier config runs the session.
  console.warn(release.fallback_reason, release.running_release_id);
} else if (stale === true) {
  // Moves the session onto the base branch's current config AND
  // fast-forwards its /workspace checkout. `workspace_checkout` says what
  // happened to the checkout; `detail` words both halves.
  const result = await s.reloadConfig();
  console.log(result.workspace_checkout, result.detail);
}

stale is tri-state. null means Beeuni could not tell. Test stale === true, never !stale. An older API omits release. See Config convergence.

s.participants() returns { participants, total, multi_user }. participants lists the people who can open the session now, owner first, at most 20, each { user_id, name, email, avatar_url, is_viewer }; total is the full count. multi_user is true when two or more people can open the session. In React, useSessionParticipants(projectId, sessionId) reads the same data. Who wrote each message is s.messageAuthors().

Three more read methods round out the handle:

  • s.previews() — candidate preview ports the runtime exposes.
  • s.publicShares.list() / .create(input) / .revoke(shareId) — public share links.
  • s.reminders.list() / .create(input) / .update(reminderId, { enabled }) / .remove(reminderId) — scheduled prompts into this session. See Reminders. .create({ transcript: true }) makes a read-only link to the conversation (one live link per session; a second call returns the same link). Read it anonymously with getPublicSessionShareMessages(token); findActiveTranscriptShare(shares) picks the live one from .list().
  • s.audit(limit?) — the session’s audit trail of agent actions.
  • s.attachments.upload(file, options?) — save a private file before startup. Maximum 50 MiB. Returns a stable URL for a prompt file part.
  • s.attachments.read(attachmentId, signal?) — read saved bytes as a Blob without starting the sandbox.
  • s.transcript(options?) — a compact server-side transcript (text and tool calls, no tool inputs or outputs). This works with a project-scoped session token.
  • s.transcriptSync(options?) — the transcript the server saves at the end of every turn, as runtime message envelopes. Every part is kept 1 — text, reasoning, and each tool call with its input, output, error and metadata — except attachment bytes. It answers while the sandbox is stopped or still waking, without starting it. Pass limit (at most 500) for the newest window, and a window’s next_cursor as before to read the one older than it. A window also stops at 1,000,000 characters of JSON: it keeps its newest messages, and next_cursor then names the oldest one it kept. Pass child (a sub-agent’s ses_… id, the session a sub-agent row opens) to read that sub-agent’s own saved transcript; every sub-agent the conversation dispatched is saved. source is mirror; complete is true only when the window holds every saved message and the saved copy reaches the session’s first message. A session whose runtime was read completely and held no messages answers available: true, complete: true, total: 0 and no messages. A tool call saved before 2026-09-28, when saved history dropped tool inputs and outputs, is served with the input its title or metadata proves, and a command also with its output.
  • s.voiceTranscript(options?) — this session’s live voice-call transcript (spoken turns plus ask_beeuni/run_command worker tool calls). Returns an empty list when the session has no live call, not a 404.

Files a session stored

findSessionAttachments(messages) lists every stored file a transcript references, in order and each once: a user’s upload, a <file> reference in a prompt, or the copy saved history keeps of a file the agent showed. It reads a live transcript and a saved one alike, and never throws on a malformed one.

import { findSessionAttachments } from '@beeuni/sdk'

const saved = await s.transcriptSync({ limit: 500 })
for (const file of findSessionAttachments(saved.messages)) {
  const blob = await s.attachments.read(file.attachment_id) // no sandbox needed
  console.log(file.filename, file.role, blob.size)
}

Each result is { url, attachment_id, filename, mime, message_id, role }. role is user for an upload and assistant for a file the agent showed; mime is null for the latter, and the bytes carry their own type.

Who wrote each message

Every message has an author. The server reads it from the credential that sent the prompt: a project member, or another session’s agent. The runtime transcript does not carry it.

const { authors, initial_author } = await beeuni.session(projectId, sessionId).messageAuthors()
// getSessionMessageAuthors(projectId, sessionId) is the same call as a function.

const author = authors[runtimeMessageId] // undefined when the sender is unknown

authors is keyed by runtime message id. initial_author is the parent session for the first message of a spawned session. It is null when the session had no first prompt. Each author is a SessionMessageAuthor:

kind Fields
'member' user_id, name, email (string | null)
'session' session_id, name (the session title), agent (the agent it runs, when named)

Which model answered a turn

A transcript message names the model its turn asked for. When the project’s fallback chain answers from another model, only the gateway’s request record holds the model that answered and what Beeuni billed.

const { latest, billed_cost, turns } = await beeuni.session(projectId, sessionId).modelUsage()
// getSessionModelUsage(projectId, sessionId) is the same call as a function.

latest?.served_model   // 'glm-5.3-flash': the model that answered the newest request
latest?.fallback_from  // 'codex/gpt-6.1-sol' when a fallback model answered, else null
turns[userMessageId]   // { served_models, fallback_from, billed_cost } for one turn

latest is null before the first answer. billed_cost is the USD Beeuni debited for the session’s model calls. turns is keyed by the runtime message id of the prompt that started each turn. served_models lists the models that answered in that turn, most requests first. In React, useSessionModelUsage(projectId, sessionId) reads the same record; call its refetch() when a turn ends.

In React, useSessionMessageAuthors(projectId, sessionId, messageCount) reads the same data. Pass the transcript’s message count. A new message is the only event that adds an author, so the query refetches when the count changes.

Readiness is a handshake

Before you send a prompt, call ensureReady(). It provisions the sandbox if needed, waits for the runtime to boot, and returns the resolved runtime.

const { runtimeSessionId, runtimeUrl, sandboxId } = await s.ensureReady();

On a cold boot, ensureReady() can throw RUNTIME_UNAVAILABLE. See Retry on a cold boot for what that means and how to retry.

s.send() and s.abort() call ensureReady() for you.

Seed a server-authorized runtime session pin

A server-rendered React host can supply the runtime session pin already persisted for the same Beeuni session:

const session = useSession(projectId, sessionId, {
  initialRuntimeSessionId: persistedSession.runtime_session_id ?? persistedSession.opencode_session_id,
});

The seed only hydrates cached transcript content while /start runs. It does not override the runtime identity. The pin returned by /start is authoritative and replaces a stale seed.

opencode_session_id is the deprecated name of runtime_session_id. Both carry the same value; read the old name only as the fallback above.

Do not accept this value from an untrusted tenant selector. Do not create a runtime session in the host. Beeuni creates and persists the root session. Runtime query caches and transcript controllers are scoped to the sandbox runtime, so equal runtime session ids from different sandboxes do not share cache entries.

Pending message retries keep their originating sandbox URL when you switch sessions. A 404 or 410 message response stops automatic retries without clearing the transcript. An explicit reconciliation can retry the read and restore normal synchronization after a successful response.

Send a prompt

s.setModel({ providerID, modelID }); // sticky for later send() calls
s.setAgent('build'); // sticky for later send() calls

await s.send('Refactor the auth module');
await s.send('One-off task', { model, agent }); // overrides for this call only
await s.abort(); // stop the current run

send() puts the prompt in the session’s durable prompt inbox (POST .../prompts), the path the web composer and the CLI use. It resolves when the prompt is stored, with the inbox row (prompt_id, state, message_id), not when the turn ends. Read the reply from s.stream() or the transcript.

For runtime REST sessions, the first send() on a handle reads the model and agent persisted on the Beeuni session. This prevents a snapshot-inherited runtime session from reusing stale snapshot defaults.

Prompt choice precedence is:

  1. The send() call.
  2. The handle’s setModel() or setAgent() value.
  3. The persisted Beeuni session default.

setModel only chooses what the next local send asks for — it never leaves the handle. To persist a new model for a running session server-side, use changeModel:

const { applied_live } = await s.changeModel('anthropic/claude-opus-4-8');

Restarting the runtime is how the change takes effect, so an in-flight turn ends. applied_live is true when a running session took it now, false when it applies at the next start. Only the session owner, or a caller with project-manager permissions, may change the model; anyone else gets 403.

send() resolves the runtime, then prompts it. abort() stops the current run without deleting the session.

Session scope and cost

Read the stored secret narrowing and materialized connection bindings. secrets_allowlist: null means the agent’s secret grant applies:

const scope = await s.scope();
scope.connector_bindings_configured; // false = inherits the project defaults

connector_bindings is the RESOLVED map, so it looks the same for a session that overrode its connectors and one that inherits the project defaults. Read connector_bindings_configured to tell them apart before rendering the scope or sending it back.

Replace one or both scope fields:

await s.rescope({
  secrets: ['DATABASE_URL'],
  connector_bindings: {
    github: { connection_id: connectionId },
  },
});

Each supplied field replaces its complete previous value. Omit a field to leave it unchanged. Connection changes apply to the next tool call. Secret removal stops future delivery but cannot remove an already disclosed value from model context or an existing process.

Both axes have an explicit way back to the default. They are not the same as an empty value:

await s.rescope({
  secrets: null, // inherit the agent's secret grant
  connector_bindings: null, // drop the override; inherit the project defaults
});

secrets: [] and connector_bindings: {} are the opposite instruction: an explicit “no project secrets” and “no connectors at all”, project defaults included. A session that sends {} where it meant null fails closed on every alias it did not name.

Read the unified cost record:

const cost = await s.cost();

The record combines finalized LLM cost, billed sandbox compute cost, model usage, token totals, compute duration, and ledger entries. s.cost() does not call ensureReady().

Runtime status and previews

Method Returns Use
s.health(init?) { status, ok, health, body } Check whether the runtime is alive
s.previewUrl(port, path?) string Get a proxy URL for a port the agent exposed
s.proxyUrl(url?) string | undefined Rewrite a localhost URL the agent printed
const { ok, health } = await s.health();
const url = s.previewUrl(3000, '/docs');

s.health() never throws. Call it any time, even before the session has a runtime. s.previewUrl() and s.proxyUrl() need a resolved runtime — call s.ensureReady() first, or they throw SessionNotReadyError. See Session readiness errors.

What the runtime supports

health.capabilities lists the session features the runtime serves. Read one with runtimeSupports(capabilities, capability) and hide the control when it returns false. A default-runtime session serves all ten; a pi session serves session.subagents only and answers the others with 501 feature_not_supported (404 for the config document). Harnesses has the full runtime capability matrix.

Capability Feature
session.rewind Revert to a message, and restore it
session.compact Summarize the conversation on demand
session.commands Project slash commands
session.fork Fork a session at a message
session.subagents Subagent child sessions
session.mcp MCP servers the runtime connects itself
session.todo The runtime’s todo list
session.shell A shell command run as a turn
session.attach Attach the harness’s own terminal client
session.config A runtime config document (/global/config); agents and models come from the project either way
import { runtimeSupports } from '@beeuni/sdk';

const { health } = await s.health();
if (!runtimeSupports(health?.capabilities, 'session.compact')) hideCompact();

runtimeSupports returns true before a health answer arrives, and for a runtime that lists no session.* capability: a daemon built before capabilities existed runs the default runtime, which serves every feature. In React, use useRuntimeSupports.

The start answer also carries the list: SessionStartResult.capabilities is set with stage: 'ready'. useSession records it, so a capability gate is right before the first health probe answers.

Status words

Every host reads one vocabulary, so one state has one word on every screen.

Export Use
sessionListStatus(session, reviewCount?) A session in a list: needs-you, starting, running, done, stopped, failed, legacy
SESSION_LIST_STATUS[status] { label, tone, description } for that status
sessionConnectionLabel(connection) A short label for the session’s computer, or null for unknown and live
SESSION_NOTICE The composer’s sentence while the computer is not ready
turnRetryLabel(secondsLeft) Retrying in 5s, Retrying now, or Waiting to retry when no countdown is known

Tones are actionable, live, progress, muted, and danger. Green is for live and actionable only; a finished session is muted. A status that a newer API sends and this build does not know reads stopped, never failed.

Streaming

Use s.stream() to receive live events in a script or server. In a React app, use useSession instead — it manages the whole session lifecycle for you.

s.stream() is the runtime REST compatibility event stream. The Beeuni API proxies it from the sandbox. There is no separate WebSocket endpoint. The transport is fetch with a streaming response body, read through ReadableStream and TextDecoderStream. The SDK handles reconnection, backoff, and a heartbeat check.

Stream a session:

  1. Call ensureReady() first. The runtime does not exist until the sandbox starts.
  2. Open the stream before you send a message, so you do not miss early events.
  3. Send the message.
  4. Close the stream when you see session.idle.
const session = beeuni.session(projectId, sessionId);
const { runtimeSessionId } = await session.ensureReady();

const stream = await session.stream({
  onEvent: (event) => {
    if (event.type === 'session.idle' && event.properties.sessionID === runtimeSessionId) {
      onTurnDone();
      stream.close();
    }
  },
});

await session.send('Refactor the auth module');

The stream delivers events in batches, 16 ms apart. Consecutive message.part.delta events for one part field in a batch arrive as one event. Its properties.delta is their text, joined in order. Its id is the last event’s id. Its coalesced array lists the events it replaced. Append delta to the part’s field, as for a single delta.

Streaming needs fetch with a real ReadableStream body and TextDecoderStream. Browsers, Node 18 and later, Bun, and Cloudflare Workers all support it.

A host whose fetch cannot stream supplies its own transport. The SDK calls it once per connection and keeps reconnect, Last-Event-ID resume, backoff and heartbeat itself:

import { configureBeeuni, notifyHostSignal, type RuntimeEventTransport } from '@beeuni/sdk';

const transport: RuntimeEventTransport = async function* ({ url, headers, signal }) {
  // Yield { data, id?, retry? } for each server-sent message of ONE connection.
  // Return when the server ends it. Throw (with `status` for a non-2xx answer)
  // when it fails.
};

configureBeeuni({ backendUrl, getToken, eventStreamTransport: transport });

// A host without `visibilitychange` / `online` events reports them itself.
notifyHostSignal('visible'); // the app is back in the foreground
notifyHostSignal('online');  // the network is back
notifyHostSignal('retry');   // a person asked to reconnect now

React Native has no streaming fetch by default. The Beeuni mobile app uses this seam with react-native-sse. createHttpSessionSyncController remains available for a host that only needs bounded history and status synchronization.

The controller loads the newest 10 messages first. loadOlder() follows the server cursor. loadHttpSessionHistory() follows every cursor for explicit exports.

Event types

Each event has a type and a properties object that holds its data, for example event.properties.sessionID.

type When it fires
message.updated / message.removed A message changed or was deleted.
message.part.updated / message.part.removed A part (text, tool call, file) grew or was removed.
session.status The session’s busy state changed.
session.idle The turn finished.
session.error The turn failed. The event carries the error.
question.asked The agent asked for input.
question.replied / question.rejected The answer to a question arrived.

Turn raw messages and parts into renderable output with classifyTurn. See SDK reference.

Retry on a cold boot

ensureReady() polls the session’s /start endpoint — each call long-polls up to 30 s — until the runtime reaches a terminal ready/failed/stopped stage or its deadline (readyTimeoutMs, default ~180 s) elapses. On a warm session the first poll resolves ready immediately. On a cold boot it keeps polling while the sandbox reports retriable: true, so a slow start just takes longer rather than throwing. It only throws an ApiError with code: 'RUNTIME_UNAVAILABLE' if the runtime is still not ready when the deadline expires.

ensureReady() is idempotent, so concurrent calls for the same session share one /start request instead of sending several. The retryUntilReady helper below is now optional — ensureReady() already retries internally — but stays useful if you want a longer total budget than the default readyTimeoutMs.

async function retryUntilReady<T>(ensure: () => Promise<T>): Promise<T> {
  const deadline = Date.now() + 300_000;
  for (;;) {
    try {
      return await ensure();
    } catch (error) {
      const provisioning = error instanceof ApiError && error.code === 'RUNTIME_UNAVAILABLE';
      if (!provisioning || Date.now() > deadline) throw error;
      await new Promise((r) => setTimeout(r, 3_000));
    }
  }
}

See Error classes for the full ApiError shape. In React, useSession retries /start for you, so you do not need this pattern.

What /start tells you

Every /start answer describes that call, not the row’s accumulated history. Four fields carry it.

Field Meaning
observed_at One clock for the whole answer.
action What the server did: inspected, checked_provider, resumed, provisioned, restored, reconciled, awaited_wake, cooling_down.
observation What the server checked. known: false means not checked on this call — never “checked and found nothing”.
boot phase (provisioning / resuming / booting / ready / parked / failed), since, and actively_starting.

boot.actively_starting answers “is a provider operation running for this session right now?”. A starting payload with actively_starting: false means the server is waiting out a retry cooldown, not that a box is booting.

{
  "stage": "starting",
  "retriable": true,
  "reason": "runtime_wake_cooldown",
  "observed_at": "2026-08-26T14:00:00.000Z",
  "action": "cooling_down",
  "boot": { "phase": "resuming", "since": "2026-08-26T13:58:00.000Z", "actively_starting": false },
  "observation": {
    "provider": { "known": false, "status": null, "checked_at": null },
    "runtime": { "known": false, "state": null, "boot_phase": null, "checked_at": null }
  },
  "failure": {
    "category": "sandbox-provider",
    "message": "The runtime did not start (attempt 2). Retrying automatically.",
    "retryable": true,
    "evidence": {
      "check": "provider_not_running",
      "observed_at": "2026-08-26T13:58:00.000Z",
      "error": null,
      "attempts": 2,
      "next_retry_at": "2026-08-26T14:03:00.000Z"
    }
  }
}

A failed start is retried for you

A start that fails stamps a cooldown, not a permanent verdict. The next /start after the cooldown re-attempts the wake by itself. The cooldown grows with consecutive failures (2 min, 5 min, 10 min). After five consecutive failures /start answers stage: "failed" with the attempt count in failure.message; that verdict expires 30 minutes after the last failure, and POST …/restart clears it immediately.

retriable is derived on every call. A state the server can still re-attempt never carries retriable: false.

failure.evidence names the check that produced the negative, when it ran, and when the server retries. Every /start failure carries it.

Prompt attachments

Upload local files when users add them to a composer. Private uploads belong to the project and do not require a running session.

import { usePromptAttachments } from '@beeuni/sdk/react';

const uploads = usePromptAttachments(projectId);
// File picker, paste, and drop handlers call uploads.add(file) or uploads.addMany(files).
// Render uploads.attachments. Refuse Send only while an attachment is `error` or `aborted`.

async function submit() {
  const ids = uploads.attachments.map((item) => item.id);
  uploads.submit(ids); // Send never waits: the composer clears and uploads continue.
  paintMessage(text, ids);
  try {
    const parts = await uploads.whenReady(ids);
    await beeuni.session(projectId, sessionId).prompts.create({
      clientMessageId,
      messageId,
      parts: [{ type: 'text', text }, ...parts],
    });
    uploads.forget(ids);
  } catch {
    uploads.reclaim(ids);
  }
}

whenReady(ids, { signal }) resolves once every upload is ready, in ids order. It rejects when an upload fails, is aborted or removed, or signal aborts. submit(ids) hands the entries to that send: they leave attachments, stop counting toward the limits, and keep uploading after the composer unmounts. Call forget(ids) after the POST succeeds, or reclaim(ids) to return them to the composer after a failed send. The resolved file part contains attachment_id, filename, and mime. It contains no URL or file bytes. The server binds and materializes the upload. Every file reaches the session’s computer, images and PDFs included: before the prompt runs, the server writes it to /workspace/uploads/.beeuni-inbox/ and the prompt references it by path. No file bytes ride in the prompt itself. With saved history on, the server also keeps a copy for the transcript. A failed copy never keeps a file from the computer. A file the server cannot write holds the prompt, which retries. Use these parts on platform session creation, warm-session start, or prompt inbox routes. Do not pass private handles directly to runtime sendParts. Legacy platform file parts with url still work.

Each item has a stable id, the original file reference, receivedBytes, and a status: pending, uploading, processing, ready, error, or aborted. Bytes sent determine progress. The controller publishes a progress snapshot only for a whole-percent change, at most ten per second per upload. processing can remain at 100% until the server completes the upload. Limits are 50 MiB per file, 100 MiB per message, and 20 files. Files must be nonempty. Two files upload concurrently by default.

retry(id) resumes the same handle. After attachment_size_mismatch or attachment_failed the server keeps no usable handle, so retry(id) uploads the File again as a new attachment. An expired upload cannot retry: retry(id) throws, and the item error carries code attachment_expired. abort(id) cancels unfinished work. remove(id) removes the entry, aborts its upload, and resolves at once. The server DELETE is best-effort and never rejects; an upload it cannot delete expires after 24 hours. forget(ids) releases entries without deleting server objects. The hook aborts listed work on unmount or project change and deletes its uploads best-effort: no send holds them, and drafts keep no handle. Handed-off uploads continue.

Selections live in memory only. Never persist File objects, blob URLs, signed URLs, or upload handles in a draft. Abandoned uploads expire after 24 hours.

Framework-free hosts use beeuni.project(projectId).attachments.createController() with the same methods. Subscribe through subscribe(listener) and read getSnapshot(). Call dispose() when the composer closes. Direct callers can use attachments.upload(file, { signal, onProgress, onUpload, resume }) and attachments.delete(attachmentId). Retain onUpload metadata for manual same-ID resumption.

The server selects the transport in the handle’s upload field:

  • kind: 'direct' is the default. The SDK sends the whole file in one PUT to upload.url with upload.headers and no Authorization header. With XMLHttpRequest (browsers, React Native), progress reports sent bytes. With fetch, progress reports 0 and then the full size. An expired URL, or one that Storage refuses with 400, 401, or 403, is re-signed once for the same attachment_id. A 409 from Storage means an earlier attempt stored the file.
  • kind: 'chunked' sends sequential authenticated PUTs of upload.chunk_size bytes. The SDK accepts any positive chunk_size. Only a deployment whose edge drops large request bodies selects this mode.

Completion verifies the stored size and SHA-256. If completion answers 409 attachment_not_uploaded, onUpload reports the direct handle with received_bytes: 0, so retry(id) or a resume sends the file again. Initiation, the upload, and completion retry timeouts, network errors, 429, and 5xx with jittered exponential backoff. The budget is 60 seconds from the first failure, so a long upload that fails late still retries. Completion also retries attachment_processing, with a five-minute budget. Initiation never retries 402 (a BillingError: the account cannot run) or 429 attachment_budget_exceeded (40 unfinished uploads or 500 MiB of unsent uploads for the user; unused uploads expire within 24 hours). The server answers or refuses one completion within 105 seconds. Each completion request allows 120 seconds. Caller aborts never retry.

A sent attachment’s reference is released 1 hour after its prompt is delivered, and when its session or project is deleted. The next maintenance sweep then removes the file, and its attachment_id can no longer be sent.

Files

s.files reads and writes the session’s sandbox: list, read, readBlob, status, findFiles, findText, upload, create, copy, remove, mkdir, rename. Every call resolves the runtime first, and always targets this session’s own sandbox. See the SDK reference for the full method list.

Session verbs

The handle reads and answers the runtime for you. Each verb provisions the session first and targets this session’s own sandbox, whichever harness runs it.

// A page of the transcript, oldest first: { info, parts } messages (beeuni.transcript.v1).
const { messages, hasMore } = await s.messages({ limit: 50 });
// What the agent waits on.
const { statuses, permissions, questions } = await s.pending();
await s.answerPermission(permissions[0].id, 'once'); // 'once' | 'always' | 'reject'
await s.answerQuestion(questions[0].id, [['Yes']]); // null dismisses the question
// Summarize the conversation. Needs the `session.compact` capability (see health()).
await s.compact();

s.runtime, the raw REST compatibility client, is deprecated. Use the verbs above with send, abort, rewind and stream.

Warm a project session

Call ensureWarm() when a project landing page needs one runtime ready before the first prompt.

const project = beeuni.project(projectId);
const warm = await project.sessions.ensureWarm();

// An ORDINARY session. Prompt it like any other.
await beeuni.session(projectId, warm.session.session_id).send("Build me a widget");

ensureWarm() creates, or returns, one unused session for the current user. It is the same create sessions.create() runs, with the project’s defaults: same billing gate, same connector requirements. The only difference is metadata.warm, which hides the session from sessions.list() until its first prompt lands.

Treat it as speculative. A 409 WARM_SESSION_UNAVAILABLE means the project cannot be warmed right now — fall through to sessions.create(), which reports the real reason.

The warm session carries the project’s DEFAULT agent and sandbox. If the user picks a different one, abandon it and call sessions.create(): an unused warm session is hidden and reaped on its own.

Handling errors

Every call through createBeeuni rejects with a typed Error subclass, never a plain object. Catch the error, check instanceof, and branch on .status or .code.

import { ApiError, BillingError } from '@beeuni/sdk';

try {
  await beeuni.project(projectId).sessions.create();
} catch (err) {
  if (err instanceof BillingError) {
    // 402 — out of credits or over a plan limit
  } else if (err instanceof ApiError) {
    // any other failed request — err.status, err.code, err.detail
  } else {
    throw err;
  }
}

Error classes

Class Extends When it throws Key fields
ApiError Error Default for any failed request: bad status, network failure, timeout, or abort status, code, detail, response, url, endpoint, timeout
AuthError ApiError getToken returned null. Beeuni never sent the request code is always 'NO_SESSION'
BillingError ApiError HTTP 402. The only billing error class status (402), code, detail.message
RequestTooLargeError ApiError HTTP 431. Usually too many files in one request detail.suggestion
SessionNotReadyError Error A runtime accessor ran before ensureReady() name is 'SessionNotReadyError'

ApiError.name is 'ApiError' by default. Two cases override it:

  • name: 'AbortError', code: 'ABORTED' — the request was cancelled, for example by navigation. This is not a failure. Ignore it.
  • code: 'TIMEOUT' — the request’s own timeout elapsed. url, endpoint, and timeout show what timed out.

For any other failure, status holds the HTTP status code. code comes from the backend’s error_code, or falls back to the status as a string. message is an enumerable own property on ApiError, so it survives JSON.stringify and object spread.

Beeuni sends a request that receives 401 once more with a fresh token from getToken(), when that token differs from the one it sent. A request with a one-shot stream body is not resent.

Beeuni retries some requests before your code sees an error. If a GET or HEAD request returns 502, 503, or 504, Beeuni retries it up to 2 times, with a 250ms then 500ms delay. A transient transport failure on a GET or HEAD — a network error, not a status code — is retried the same way. A retry that succeeds never reaches onError. Beeuni never retries POST, PUT, PATCH, or DELETE requests, or a 500 response.

Beeuni throws AuthError on the client, before it sends a request, when getToken() returns null. AuthError extends ApiError, so err instanceof ApiError still matches. Check err instanceof AuthError, or err.code === 'NO_SESSION', to treat “not signed in” as a separate case from a backend failure.

Beeuni throws BillingError for every HTTP 402 response: out of credits, over a plan limit, or another billing gate. detail.message holds the reason from the backend, and code holds the backend’s machine code (for example app_account_unfunded). BillingError extends ApiError, so check BillingError first when you handle both.

Beeuni throws RequestTooLargeError for HTTP 431. This usually means the request carried too many files. detail.suggestion holds a ready-to-show hint for the user.

Session readiness errors

Two errors mean the session’s sandbox is not ready yet. Handle each one differently.

SessionNotReadyError throws synchronously when you call a runtime accessor — session.previewUrl() or session.proxyUrl() — before this session handle has resolved its sandbox. A session handle only resolves its own sandbox; it never falls back to another session’s sandbox.

import { SessionNotReadyError } from '@beeuni/sdk';

const s = beeuni.session(projectId, sessionId);
try {
  const url = s.previewUrl(3000); // throws: not resolved yet
} catch (err) {
  if (err instanceof SessionNotReadyError) {
    await s.ensureReady();
  }
}

Call await session.ensureReady() first, or call send(), which readies the session internally. session.health() is the one accessor that never throws this error, so you can poll it before the session boots.

RUNTIME_UNAVAILABLE is the second error — it means ensureReady() itself timed out waiting for a cold boot. See Retry on a cold boot for the full pattern. In React, useSession retries this for you and exposes it through the phase value instead of throwing.

Helpers

Helper Signature What it does
parseBillingError(error) (error) => Error Wraps a 402 response into a BillingError. Returns other errors unchanged
isBillingError(error) (error) => boolean Returns error instanceof BillingError
formatBillingErrorForUI(error) (error) => BillingErrorUI | null Returns null for non-billing errors. Otherwise returns { alertTitle, alertSubtitle } for an upgrade modal
isRuntimeNotReadyResponse(error) (error) => boolean True for the sandbox daemon’s 503 while the session runtime cannot take a request yet. The request was not forwarded, so a retry is safe. Same answer across supported runtimes
isSandboxNotReadyError(error) (error) => boolean True for the answer above and for a sandbox that is stopped, starting or parked. Render it as “waking” and keep polling
isRuntimeStartingError(error) (error) => boolean True for both answers above and for a runtime URL that is not pinned yet. An error boundary retries on it instead of showing a crash
import { formatBillingErrorForUI } from '@beeuni/sdk';

try {
  await beeuni.session(projectId, sessionId).start();
} catch (err) {
  const ui = formatBillingErrorForUI(err);
  if (ui) showUpgradeModal(ui.alertTitle, ui.alertSubtitle);
}

In @beeuni/sdk/react

@beeuni/sdk/react re-exports BillingError, RequestTooLargeError, parseBillingError, isBillingError, and formatBillingErrorForUI. It does not re-export ApiError or AuthError — import those from @beeuni/sdk.

useSession classifies every send, answerQuestion, answerPermission, and rejectQuestion failure into one sendError object, so you do not need to write instanceof checks by hand:

interface BeeuniSendError {
  kind: 'billing' | 'runtime-not-ready' | 'runtime-error';
  message: string;
  billing?: BillingError; // set when kind is 'billing'
  cause: unknown;
}
const s = useSession(projectId, sessionId);

if (s.sendError?.kind === 'billing') {
  const ui = formatBillingErrorForUI(s.sendError.billing);
}

See React hooks for the rest of useSession.

Was this page helpful?