# Valkor full public content corpus > The open-source AI command center for your company. Every agent, skill, and memory is a file in one versioned repo you own — a workforce of AI agents that does real work, shared across your whole team from Slack, Teams, the web, or the CLI. Self-hostable, any model, your keys. # Valkor – The AI Command Center for Your Company The open-source AI command center for your company. Every agent, skill, and memory is a file in one versioned repo you own — a workforce of AI agents that does real work, shared across your whole team from Slack, Teams, the web, or the CLI. Self-hostable, any model, your keys. Canonical page: https://beeuni.ai/ ## Official resources - [Documentation](https://beeuni.ai/docs) --- # Valkor for developers Build, test, and ship software with Valkor's agent workspace. Canonical page: https://beeuni.ai/developers ## Official resources - [Documentation](https://beeuni.ai/docs) --- # Accounts & access An account holds your projects, your teammates, and one access model. Canonical page: https://beeuni.ai/docs/accounts An account holds your [projects](/docs/project) and the people who work in them. When you sign up, Valkor creates a personal account for you. Invite a teammate and it becomes a team account. Personal and team accounts use the same roles, billing, and limits. ## One access model Valkor has one grant record: an **assignment**. Every assignment binds one principal to one role, at one scope. | Term | Meaning | |---|---| | **Principal** | Who holds the access. A `user`, a `group`, a `service_account` (an agent's identity), or a `pending` invitee email. | | **Role** | A named set of permissions. Valkor ships the built-in roles below. An account can add custom roles. | | **Permission** | One action, for example `project.secret.read` or `member.update`. | | **Scope** | Where the role applies: the whole `account`, or one `project`. | | **Object** | Optional. Narrows the assignment to one `agent`, `skill`, `secret`, `app`, or `trigger` inside the scope. | | **Expiry** | Optional. The assignment stops granting at `expires_at`. | There is no second grant store. A group's access, a per-resource grant, and a custom-role binding are all assignments. They differ only in the principal, the object, and the role. ## Account roles Each person in an account holds one account role: - **Owner** — full control, including members and billing. - **Admin** — manages projects, members, groups, roles, and tokens. - **Member** — works only in the projects they hold a project assignment on. Owner and admin hold manager-equivalent access on every project in the account. A project assignment cannot lower that. To limit an owner or an admin on one project, first change their account role to member. Invite a teammate by email from the account's members page. The invitation is an assignment on a `pending` principal. It becomes a `user` assignment when they accept. ## Project roles Inside a project, a principal holds one of two roles: - **Member** — reads the project, starts and stops its sessions, fires its triggers. No editing, no configuration. - **Manager** — every project permission: edits the project, manages triggers, connectors, skills, secrets and access, holds the gateway keys, and deletes the project. There is no third project role. Manager is the full set of project permissions. A custom role only adds permissions, so no role can withhold one from a manager. [Object assignments](#object-assignments) are the separate mechanism that narrows *which objects* a permission reaches. Set project access from the project's access settings, or with `valkor access` — see [CLI](/docs/cli#access). ## Groups A group is a principal, exactly like a person. Assign a role to a group at a scope and every member of that group holds it. A group adds access. It never removes access a person already holds. A group provisioned by SCIM is the same principal type as one you create by hand. ## Object assignments An assignment can name one object inside a project: an `agent`, a `skill`, a `secret`, an `app`, or a `trigger`. Valkor enforces object assignments on agents and skills today. - **Agents are closed by default.** A member reaches an agent only when an assignment names them, or names one of their groups. - **Every other object type is open by default.** With no assignment on the object, a member reaches it. - **An object assignment restricts a project manager too.** That is what makes "scope this agent to the finance group" mean anything. What the manager role buys is the unscoped default, not an exemption. - **Account owners and admins are never restricted by an object assignment**, and neither is a service account acting on its own assignments. An object assignment carries no permissions of its own. It answers "which objects", not "which actions". ## Expiry Give an assignment an `expires_at` and it stops granting at that instant. The engine ignores an expired assignment. The row stays for the audit trail. ## One vocabulary, two bindings A person, a group, or an agent gets access from Valkor as **roles** — the assignments described above. An agent carries a **second, separate binding**: the Valkor CLI scopes its manifest declares in `valkor.yaml` under `agents..valkor_cli`. A session can only do what both allow. The two never widen each other. An agent whose manifest lists `project.secret.read` still reads nothing when the role verdict denies it, and an agent launched by an owner still reads nothing when its manifest does not list the action. Roles are account state; CLI scopes are repository state in the manifest. See [Manifest reference](/docs/project/manifest#agents). ## Custom roles A custom role is an account-owned role with the permissions you choose. Assigning one writes a single assignment — there is no built-in baseline row beneath it. A custom role can grant project access with no built-in project role at all, which is how a department-style role works. A custom role only adds permissions. Valkor has no deny rule. Custom roles need the enterprise `rbac` entitlement. Without it, assigning one answers `402` with `code: "entitlement_required"`. ## Super-admin Super-admin is not a role. It is a flag on one account membership. It bypasses every permission check, and every bypass is written to the audit log. Roles are the mechanism for ordinary access; super-admin is the audited escape hatch. ## Per-feature access settings Some features carry their own visibility setting on top of the role model: | Setting | Decides | |---|---| | [App access mode](/docs/feature-flags/apps#access-modes) | Who can open one deployed App | | [Trigger session access](/docs/connect/triggers#session-access) | Who can open the sessions one trigger creates | | [Slack channel policy](/docs/connect/slack) | Who can start a session from one channel | | [Computer Tunnel capability grants](/docs/connect/computers#grant-access) | Which filesystem, shell, and desktop calls a machine accepts | Each one narrows access to one resource. None of them grants a permission the role verdict denies. ## The access API | Method + path | Does | |---|---| | `GET /v1/accounts/{accountId}/iam/assignments` | List assignments. Filter 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, built-in and custom. | | `GET /v1/accounts/{accountId}/iam/roles/{roleId}/permissions` | One role's permissions. | The catalog is data, not a hardcoded list. Each permission carries its `action`, `scope_type`, `resource_type`, `delegable` flag, `description`, `area`, `level`, and `implies`. Read it instead of hardcoding action strings. The write routes choose the permission they require from what you are granting: `project.members.manage` for a project role or an object assignment, `member.update` for an account role, `policy.create` for a custom role. You cannot side-step a ceiling by picking a different route. ## Branding An Enterprise account can put its own brand on the app for every member: a wide logo, a square icon, and a favicon, each with an optional dark-mode variant, plus a product name that replaces "Valkor" in the browser tab title. Open **Account → Branding** (`/accounts/{accountId}?tab=branding`). Uploads accept PNG, JPEG, WebP, SVG, and ICO up to 1 MB; the icon stands in for a missing logo, the favicon falls back to the icon, and a missing dark variant falls back to the light image. In-app marks follow the app theme; the favicon follows the operating system's color scheme. Branding follows the account, not the browser: inside a project, members see the brand of the account that owns the project. Sign-in pages, emails, and public share pages stay Valkor, because there is no account to brand until someone is signed in. When the Enterprise entitlement lapses, members see Valkor again — nothing is deleted, and the account can remove what it uploaded at any time. | Method + path | Does | |---|---| | `GET /v1/accounts/{accountId}/branding` | The stored record and whether the plan allows it. | | `PUT /v1/accounts/{accountId}/branding` | Set or clear `app_name`. Needs `account.write` and the `branding` entitlement (`402 entitlement_required` otherwise). | | `POST /v1/accounts/{accountId}/branding/assets/{kind}` | Upload one image as multipart `file`. `kind` is `logo`, `icon`, `favicon`, or their `_dark` variants. Same gate as `PUT`. | | `DELETE /v1/accounts/{accountId}/branding/assets/{kind}` | Remove one image. `account.write` only. | | `DELETE /v1/accounts/{accountId}/branding` | Reset everything to Valkor. `account.write` only. | `GET /v1/accounts` carries each account's effective `branding` — the record while entitled, `null` otherwise — so a client renders from one request it already makes. In the SDK: `valkor.accounts.branding.{get, update, uploadAsset, removeAsset, reset}`. ## Switching accounts If you belong to more than one account, switch between them from the account switcher. Each account keeps its own projects, members, and settings. ## Tokens Valkor signs you in with a personal access token (`valkor_pat_...`). It acts as the user who created it and holds exactly that user's assignments. A service account (`valkor_sa_...`) is a separate principal, not a person's credential, and holds no project access until an assignment gives it some. The two live on two surfaces, because they belong to two different owners. Your own API keys are in your settings, at **Settings → API keys** (`/settings/tokens`) — only you see them, and they stop working when your membership does. Service account tokens are account configuration, at **Account → Tokens**, beside the key rules that govern expiry. See [SDK authentication](/docs/sdk/auth) for the full token model. ## Billing Billing applies at the account level and covers every project in the account. Valkor offers a free tier, a pro tier, and a per-seat team tier, each with its own model access and credits. Using your own model key does not make usage free: Valkor still charges a platform fee on top of it, except on the free tier. > **Enterprise** > Single sign-on (`sso`), SCIM provisioning (`scim`), custom roles (`rbac`), and audit access (`auditAccess`) are entitlements on the enterprise tier. An entitlement is orthogonal to a role: it decides whether the feature exists for the account, and the role still decides who may use it. Contact sales to enable them. --- # Valkor as a Backend Start and manage Valkor sessions from your backend with explicit connector, model, context, and secret scope. Canonical page: https://beeuni.ai/docs/backend Use a Valkor API key to start sessions from your server. Each session has one Valkor owner, one project, and one cost record. Your application owns its customer identifiers and metadata. Store the relationship between your customer and the returned `session_id` in your application database. ## 1. Get an API key Create a personal access token (`valkor_pat_…`) or service-account credential (`valkor_sa_…`). Both authenticate a programmatic session-create request. The API derives `origin: "backend"` from the credential type. ```bash export VALKOR_API_URL="https://your-valkor-deployment.com/v1" export VALKOR_API_KEY="valkor_pat_…" export VALKOR_PROJECT_ID="…" ``` Use a service account when you need an independently managed principal. A service account is the `service_account` principal type. It has no membership, so it holds only the roles assigned to it directly. Assign it a project role before use — see [Accounts & access](/docs/accounts#one-access-model). ## 2. Start a session ### Create with HTTP ```bash curl -X POST "$VALKOR_API_URL/projects/$VALKOR_PROJECT_ID/sessions" \ -H "Authorization: Bearer $VALKOR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "agent_name": "support", "opencode_model": "valkor/glm-5.2", "runtime_context": { "ticket_id": "ticket-123" }, "connector_bindings": { "gmail": { "connection_id": "" } }, "secrets": ["STRIPE_KEY"] }' ``` ### Create with the SDK ```ts import { createScopedValkor } from '@valkor/sdk/server'; const valkor = createScopedValkor({ backendUrl: process.env.VALKOR_API_URL!, getToken: async () => process.env.VALKOR_API_KEY!, }); const session = await valkor.project(projectId).sessions.create({ agent_name: 'support', opencode_model: 'valkor/glm-5.2', runtime_context: { ticket_id: 'ticket-123' }, connector_bindings: { gmail: { connection_id: connectionId }, }, secrets: ['STRIPE_KEY'], }); ``` Use `createScopedValkor` when one server process handles concurrent requests. Each client keeps its token and runtime state request-scoped. Store the `session_id` returned by either create call: ```bash export SESSION_ID="" ``` ### Session-create fields | Field | Contract | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agent_name` | Selects a declared OpenCode agent. | | `opencode_model` | Selects the initial OpenCode model. An unavailable model returns `400 INVALID_SESSION_MODEL`. | | `runtime_context` | Stores non-secret scalar context. The API rejects credential-like keys, more than 64 entries, or more than 16 KiB. | | `connector_bindings` | Maps a connector slug to one strategy-compatible `connection_id`. The credential stays outside the sandbox. | | `inherit_unbound` | Keeps strategy-based default resolution for connectors omitted from an explicit binding map. The default is `false`. | | `secrets` | Narrows the selected agent's project-secret grant. An empty list delivers no project secrets. Only backend-origin callers can set it. | | `require_connectors` | Adds mandatory connectors for this create request. Missing connections return `409 CONNECTOR_CONNECTION_REQUIRED`, and unconfigured slugs return `409 REQUIRED_CONNECTOR_CONNECTION_UNAVAILABLE`, before the session row is inserted and before sandbox startup. | ## 3. Configure connectors and connections A connector defines the tool surface. It contains a project-unique slug, display name, provider app, authorization strategy, and policies. A connection stores one connected account or credential for that connector. Every connection inherits the connector's policies. The authorization strategy has two values: - `project` accepts active project connections. - `user` accepts only an active connection owned by the acting project member. A service account is a principal, but it is not a person, so it cannot use a member's `user` connection. Use `project` connectors for service-account sessions. A personal access token can use an eligible `user` connection owned by the token's member. ```yaml connectors: - slug: gmail-read name: Gmail read only provider: pipedream app: gmail authorization_strategy: project policies: - match: search_email action: always_run agents: support: connectors: [gmail-read] connectors_required: [gmail-read] ``` The SDK exposes connections under `project.connectors.connections`: ```ts const connection = await valkor.project(projectId).connectors.connections.reconcile({ connector_alias: 'gmail-read', owner_type: 'project', label: 'Support inbox', }); await valkor .project(projectId) .connectors.connections.updateCredential(connection.connection_id, { value: credential, kind: 'secret', }); await valkor.project(projectId).connectors.connections.activate(connection.connection_id); ``` For a Pipedream OAuth connection, call `pipedreamConnect()` and `pipedreamFinalize()`. Do not place its provider token in `updateCredential()`. > **Info** > The connection object and new session binding input use `connection_id`. > `authorization_id` remains a deprecated SDK input alias. ## 4. Read and replace session scope The session scope is authoritative server state. `secrets_allowlist` contains the session's stored narrowing. A `null` value means the agent grant applies. `connector_bindings` contains the materialized connection selection. ```bash curl -sS \ "$VALKOR_API_URL/projects/$VALKOR_PROJECT_ID/sessions/$SESSION_ID/scope" \ -H "Authorization: Bearer $VALKOR_API_KEY" ``` ```ts const scope = await valkor.session(projectId, sessionId).scope(); ``` Replace scope with `PUT` or `rescope()`: ```ts const nextScope = await valkor.session(projectId, sessionId).rescope({ secrets: ['STRIPE_KEY'], connector_bindings: { 'gmail-read': { connection_id: connection.connection_id }, }, }); ``` Each supplied field uses set semantics. The new value replaces the complete previous value. Omit a field to leave it unchanged. Connector changes apply to the next tool call. Secret removal stops future delivery. It cannot remove a value from an existing model context or process. Rotate the secret when prior disclosure matters. ## 5. Read session costs The session-cost API combines finalized LLM cost and billed sandbox compute cost. Every session appears in the list, including sessions with zero cost. ```bash curl -sS \ "$VALKOR_API_URL/usage/session-costs?project_id=$VALKOR_PROJECT_ID&limit=25&offset=0" \ -H "Authorization: Bearer $VALKOR_API_KEY" curl -sS \ "$VALKOR_API_URL/usage/session-costs/$SESSION_ID?project_id=$VALKOR_PROJECT_ID" \ -H "Authorization: Bearer $VALKOR_API_KEY" ``` The list returns session, project, owner, LLM, compute, total, request, token, model, and compute-duration fields. It also returns `reconciliation` for account usage that has no session. The detail response adds: - `model_usage`, grouped by provider and model - `ledger_entries`, with discriminated `llm` and `compute` rows Use the SDK for typed reads: ```ts const page = await valkor.billing.sessionCosts.list({ accountId, projectId, limit: 25, offset: 0, }); const detail = await valkor.billing.sessionCosts.get(sessionId, { accountId, projectId, }); const sameDetail = await valkor.session(projectId, sessionId).cost(); ``` `session.cost()` does not start the session runtime. ## 6. Stream the answer Await runtime readiness before using the OpenCode REST methods: ```ts const handle = valkor.session(projectId, session.session_id); await handle.ensureReady(); const stream = await handle.stream({ onEvent: (event) => { // Render or persist the event. }, }); await handle.send('Summarize the support queue.'); ``` Use `useSession(projectId, sessionId)` for React hosts. It owns startup, readiness, the live event stream, and message synchronization. ## Idempotent retries Generate one `Idempotency-Key` for each logical session-create operation. Reuse that key only when the request body is identical. A replay with the same key and body returns the same session. A replay with a different secret allowlist, connector binding map, or runtime context returns `409`. ## Security rules - The API derives session origin from the credential. The request body cannot select it. - Connector credentials resolve server-side for each tool call. - A connection must match its connector's authorization strategy. - Connector policies apply to every connection under that connector. - Project guardrails apply above connector-connection policies. - Secret scope can narrow an agent grant. It cannot widen one. - A session can only do what the role verdict and the agent's manifest grant both allow. Neither one widens the other. See [One vocabulary, two bindings](/docs/accounts#one-vocabulary-two-bindings). - Session scope replacement cannot select a connection owned by another member. - Store application customer metadata outside Valkor. ## Common errors | Status | Code | Meaning | | ---------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- | | `400` | `INVALID_SESSION_MODEL` | The selected model is not available to the account. | | `400` | `INVALID_SESSION_CONNECTOR_BINDINGS` | The binding map is malformed. | | `400` | `INVALID_SESSION_RUNTIME_CONTEXT` | Runtime context violates its shape, key, entry, or size limits. | | `403` | `origin_override_forbidden` | A non-backend caller supplied a secret allowlist. | | `403` | `CONNECTOR_NOT_ASSIGNED` | The selected agent is not granted the connector. | | `404` create / `403` rescope | `CONNECTOR_CONNECTION_NOT_FOUND` | The connection does not exist in this project or violates the connector's authorization strategy. | | `404` | `SECRET_IDENTIFIER_NOT_FOUND` | The secret allowlist contains an unknown project-secret identifier. | | `409` | `CONNECTOR_CONNECTION_REQUIRED` | A mandatory connector has no valid active connection. Every failing connector is listed in `connector_connections`. | | `409` | `REQUIRED_CONNECTOR_CONNECTION_UNAVAILABLE` | A required slug has no configured connector at all. Every failing alias is listed in `connectors`. | | `409` | `CONNECTOR_PROVIDER_UNSUPPORTED` | The alias is a connector on the project but its provider has no hosted authorization page, so no connect link exists for it. | | `409` | `CONNECTOR_PIPEDREAM_APP_MISSING` | The Pipedream connector names no app, so no connect link can be built. | | `409` create / `403` rescope | `CONNECTOR_CONNECTION_INACTIVE` | The selected connection or connector is inactive. | | `409` | `IDEMPOTENCY_*_CONFLICT` | The idempotency key was replayed with a different request body. | | `402` | `subscription_required` / `insufficient_credits` | The account cannot start a billed session. | --- # CLI The valkor command line, its auth model, the dev loop, and every command. Canonical page: https://beeuni.ai/docs/cli The `valkor` command line interface (CLI) controls Valkor from a terminal — your laptop or a session sandbox. This page shows the everyday dev loop, then lists every stable command and flag. ## Install ```sh curl -fsSL https://valkor.com/install | bash ``` The installer downloads a prebuilt binary for macOS and Linux. Windows is not supported. | Command | Effect | | --- | --- | | `valkor update` | Re-run the install script and pull the latest binary. | | `valkor uninstall [-y\|--yes] [--keep-auth] [--keep-home]` | Remove the binary, the `/usr/local/bin` shim, and the stored token. `--keep-auth` keeps the token. `--keep-home` keeps `~/.valkor`. | | `valkor version` | Print the CLI version. | ## Auth model Valkor stores authentication per host, not globally. A host is one Valkor API endpoint. Four hosts exist by default: `cloud` (Valkor Cloud), `selfhost` (your self-hosted stack), `local-dev`, and `valkor-internal-dev`. You can add more. The config file lives at `~/.config/valkor/config.json`, mode `0600`. Override its path with `VALKOR_CONFIG_FILE`. The CLI follows one hierarchy: host → account → project → session. You sign in to a host, pick an account inside it, pick a project inside that account, and open sessions inside the project. `valkor hosts login` walks the first three steps in order: it signs you in, picks the account, then sets a default project. Every token starts with `valkor_pat_`. A user token, from `valkor login`, sees every account and project you belong to. A project token is auto-minted for a session sandbox and scoped to one project. See [Token scope](#token-scope). ## The dev loop This loop assumes the CLI is installed and you ran `valkor login`. See [Quickstart](/docs/quickstart) for setup. ### Link a repo Start a new project, or link an existing repo folder to one. To scaffold a new project: ```sh valkor init my-app cd my-app ``` `valkor init` creates a project directory with the general-purpose starter. Its `valkor.yaml` declares `valkor_version: 2` and runs OpenCode. To link an existing cloned repo to a project you already created: ```sh valkor projects link ``` This command writes `.valkor/link.json` in the current directory. Valkor reads this file to find your project on every command run from this folder. If you plan to run `valkor ship` first, skip this step. It links a new project for you when none exists. ### Ship your code ```sh valkor ship ``` `valkor ship` lints your `valkor.yaml`, commits local changes, pushes your branch, and prompts for any missing secret or connection. Run it each time you want your local changes on the cloud project. The first run also creates the cloud project and repo if you have not linked one yet. ### Run and attach to sessions Start a session with a prompt: ```sh valkor sessions new --prompt "Build the login page" --wait ``` Each session runs in its own sandbox, on its own branch. `--wait` blocks until the session is ready. Attach to a session from your terminal: ```sh valkor connect ``` With no session id, `valkor connect` (alias: `attach`) opens a session picker for the bound project — running sessions attach immediately, stopped ones boot first, and `+ New session` starts a fresh sandbox — then lands you in the full OpenCode TUI attached to that session. Pass an id to skip the picker: `valkor connect `. The CLI manages the `opencode` binary for you: on first connect it downloads the exact version the session's server runs and caches it under `~/.valkor/opencode//`, so the TUI and server never skew. Set `VALKOR_OPENCODE_BIN` to force your own binary. For a lighter-weight line-based chat instead of the full TUI, run: ```sh valkor sessions chat ``` This opens an interactive chat with your most recent session. Add an id to target a specific session: `valkor sessions chat `. To open a raw shell in the sandbox, with no agent involved, run: ```sh valkor sessions shell ``` List your running sessions at any time: ```sh valkor sessions ls ``` ### Review with change requests An agent opens a change request (CR) when its session has commits ready to merge. List, inspect, and merge them from the CLI. ```sh valkor cr ls valkor cr diff 1 valkor cr merge 1 ``` `valkor cr ls` lists change requests for the linked project. `valkor cr diff ` shows the unified patch. `valkor cr merge ` merges it into the project's default branch. Accept a CR number or its full id. ## Reference ### Auth commands | Command | Effect | | --- | --- | | `valkor login [--host ] [--api ] [--token ] [--account ] [--no-project]` | Sign in to the active host, or the named one. Opens a browser by default; `--token` signs in headless. `--no-project` skips the default-project pick. | | `valkor logout [--host ]` | Remove the token for the active host, or the named one. | | `valkor whoami [--host ] [--json] [--token-only]` | Print the signed-in user and active account. | | `valkor token [--host ]` | Shortcut for `valkor whoami --token-only`. | `valkor hosts login` / `hosts logout` / `hosts whoami` are the canonical forms. `login` / `logout` / `whoami` are shortcuts that act on the active host. ### Hosts | Command | Effect | | --- | --- | | `valkor hosts ls [--json]` | List every host and its auth status. | | `valkor hosts login [] [--token ] [--api ] [--account ] [--no-project]` | Sign in to a host. An unknown name registers the host first. | | `valkor hosts logout []` | Remove the token for a host. | | `valkor hosts use ` | Switch the active host. | | `valkor hosts add --url [--dashboard-url ] [--login]` | Register a new host. `--login` signs in right after. | | `valkor hosts rm [--force]` | Remove a host. | | `valkor hosts info [] [--json]` | Show details for one host. | | `valkor hosts current [--json]` | Print the active host name. | A remote host URL that starts with `http://` is normalized to `https://`. The CLI never sends a token over plain HTTP to a remote host. `localhost` is exempt. ### Accounts | Command | Effect | | --- | --- | | `valkor accounts ls [--json]` | List the accounts you belong to on the active host. | | `valkor accounts use []` | Switch the active account. | | `valkor accounts current [--json]` | Print the active account. | | `valkor accounts info [] [--json]` | Show one account. | ### Members Who belongs to the account, and at what account role. Roles are `owner`, `admin`, and `member`. Owners and admins hold implicit Manager on every project, so `member` is the only role that takes per-project grants. | Command | Effect | | --- | --- | | `valkor members ls [--json]` | List members, roles, and project counts. | | `valkor members invite --role admin\|member [--project :]` | Invite by email. An existing Valkor user is added immediately; anyone else is mailed an invite link. `--project` is repeatable and applies on accept. Needs `member.invite`. | | `valkor members set-role --role owner\|admin\|member` | Change an account role. Needs `member.update`; the `owner` role is owner-only. | | `valkor members rm [-y\|--yes]` | Remove a member and revoke their tokens. Needs `member.remove`. | | `valkor members super-admin on\|off` | Grant or revoke the super-admin bypass. Needs `member.super_admin.grant`. | | `valkor members invites ls [--json]` | List pending invitations you sent. | | `valkor members invites cancel ` | Cancel one pending invitation. | | `valkor members invites resend ` | Re-send the email and refresh the 14-day expiry. | A `` is a user id, or the email of someone already in the account. Options: `--account `, `--host `, `--json`, `-y`. ### Groups An account group is a named set of people you grant a role to once. Bind a group to a scope with `valkor access grant --group --role `; revoke with `valkor access revoke `. | Command | Effect | | --- | --- | | `valkor groups ls [--json]` | List groups with member and project counts. | | `valkor groups create [--description ]` | Create a group. | | `valkor groups set [--name ] [--description \|--no-description]` | Rename or re-describe a group. | | `valkor groups rm [-y\|--yes]` | Delete a group. Its grants go with it. | | `valkor groups members [--json]` | List a group's members. | | `valkor groups add ...` | Add one or more people. | | `valkor groups remove ` | Remove one person. | | `valkor groups projects [--json]` | Which projects the group reaches, and at what role. | A `` is a group id or its exact name. Reads need `group.read`. `create`, `set`, and `add` need `group.update` or `group.members.manage`, plus the enterprise `rbac` entitlement. `rm` and `remove` are cleanup and are never entitlement-gated. ### Tokens Non-interactive credentials for the account. Reads need `token.read`, minting needs `token.create`, revoking needs `token.revoke`. | Command | Effect | | --- | --- | | `valkor tokens ls [--mine] [--json]` | List the account's personal API keys. `--mine` narrows to the ones you minted. | | `valkor tokens new [--expires ] [--project ]` | Mint a key. The secret prints once. `--project` binds it to one project, which it can never leave. | | `valkor tokens rm [-y\|--yes]` | Revoke a key immediately. | | `valkor tokens service-accounts ls [--json]` | List service accounts. | | `valkor tokens service-accounts new [--description ] [--expires ]` | Create one. The bearer prints once. | | `valkor tokens service-accounts disable ` | Disable a service account. Reversible only by deleting and re-creating. | | `valkor tokens service-accounts rm [-y\|--yes]` | Delete a service account permanently. | A personal API key acts as you and dies with your membership. A service account acts as itself and inherits no access: a new one holds no permissions. Grant it one with `valkor access grant --service-account --role `. `--expires ` takes ISO-8601 or a forward span: `30d`, `12h`, `6w`, `1y`. ### Billing Read the active account's plan, credits, and spend. Read-only: plan changes, top-ups, and payment methods are dashboard flows. | Command | Effect | | --- | --- | | `valkor billing status [--json]` | Plan, credits, seats, subscription. | | `valkor billing transactions [--limit ] [--offset ] [--type ] [--json]` | Credit ledger, newest first. Default page size 50. | | `valkor billing transactions --summary\|--breakdown\|--usage [--days ]` | Credits in/out, the balance split (expiring/non-expiring/daily), or a credit-usage summary. `--days` is the window for `--summary` and `--usage`; default 30. | | `valkor billing costs [--since ] [--until ] [--json]` | Account spend over a window, plus a model breakdown. Default window: 30 days, half-open `[from, to)` UTC. | | `valkor billing costs --by project\|session [--sort ] [--limit ] [--offset ] [--csv ]` | Roll spend up by project or by session. `--sort` takes `total_desc` (default), `total_asc`, `recent`, or `name_asc` (`--by project` only). `--csv` needs `--by`. | Filters: `--project `, `--session `, and `--owner ` (sessions, with `--by session`). Options: `--account `, `--host `, `--json`. ### Projects A command resolves "the project" in this order: 1. The `--project` flag. 2. The `VALKOR_PROJECT_ID` environment variable. 3. `.valkor/link.json` in the exact working directory. 4. The global default set by `valkor projects use`. | Command | Effect | | --- | --- | | `valkor projects ls [--all] [--query ] [--json]` | List projects on the active account. `--all` spans every account you belong to. `--query` (alias `-q`) filters by name, id, or repo. | | `valkor projects info [] [--json]` | Show one project. Default: the linked or default project. | | `valkor projects use []` | Set the global default project. Switches the active account if the project lives elsewhere. | | `valkor projects unset` | Clear the global default project. | | `valkor projects link []` | Bind the current directory to a project. Writes `.valkor/link.json`. | | `valkor projects unlink` | Remove `.valkor/link.json`. | | `valkor projects open []` | Open a project's dashboard page in your browser. | | `valkor projects clone [] [dir]` | Clone a project's repo through the authenticated Valkor git proxy. | | `valkor projects rm [] [--purge] [-y\|--yes]` | Archive a project. `--purge` also deletes its managed git repo. | | `valkor projects set [] [--name ] [--branch ] [--manifest ] [--json]` | Update one project's settings. Only the fields you pass are written. Passing no field exits `2`. Alias: `update`. | | `valkor projects set [] --icon \|--no-icon\|--glyph :\|--no-glyph` | Set or remove the project's icon. | | `valkor projects rename [] ` | Alias for `valkor projects set --name `. | | `valkor projects features [ls] [--json]` | List every feature flag with its key, state, origin, and stability. | | `valkor projects features enable\|disable\|reset ` | Set the project override on, off, or clear it so the flag follows the platform default. | | `valkor projects cli-tokens ls [--json]` | List the project's CLI tokens. | | `valkor projects cli-tokens new [--name ]` | Mint a project-scoped CLI token. The secret prints once. | | `valkor projects cli-tokens rm [-y\|--yes]` | Revoke one project CLI token. | | `valkor projects upgrade [] [--json]` | Start the agent session that migrates a v1 `valkor.toml` to a v2 `valkor.yaml` and opens a change request. | A project shows one icon, so writing `--icon` clears the glyph and writing `--glyph` clears the emoji; passing both is refused. Glyph colors: `grey`, `red`, `orange`, `yellow`, `lime`, `blue`, `purple`, `magenta`. `set` and `features` need `project.customize.write`. A flag the platform marks unavailable stays off regardless of the project override. A project CLI token is bound to one project — the API rejects it everywhere else. A session sandbox uses its session-bound `VALKOR_TOKEN`. `cli-tokens ls` needs project read; `new` and `rm` need `project.credentials.issue`. An agent-session token can neither mint nor revoke project tokens (`403`). `valkor projects upgrade` needs project write. The default agent refreshes the marketplace baseline, rewrites the manifest, runs `valkor validate`, and opens a change request. It never merges: a human reviews the diff. ### Project scaffold `valkor init [project-name] [options]` creates a new project directory. The starter writes a v2 `valkor.yaml` and the canonical `.valkor/opencode` system-skill source. The command can wire local coding-tool discovery without changing the cloud OpenCode runtime. The command does not write `.valkor/link.json`. `valkor ship` or `valkor projects link` create that file. | Flag | Meaning | | --- | --- | | `--name ` | Project name. | | `--primary ` | Primary agent. | | `--agents ` | Local coding-agent integrations to wire up. | | `--force` | Configure the current directory in place instead of scaffolding a new one. | | `--overwrite` | Overwrite existing files. | | `--no-git` | Skip git init. | | `-y, --yes` | Don't prompt. | The local coding-tool selection does not change the cloud OpenCode runtime. `valkor init` does not include a marketplace picker. Adding a marketplace skill is an agent import: start a session and ask the agent to bring one in. ### Ship `valkor ship` stages, commits, and pushes your current branch to the project's git repo. Run it once to create the project. Run it again any time to sync. Alias: `valkor deploy`. Each run: - Parses and validates `valkor.yaml` (skip with `--no-verify`). - Commits any dirty working tree (skip with `--no-commit`). - Prompts for any missing `env` secret (skip with `--no-env`). - Pushes the current branch to the same-named remote branch. - Connects any declared connector that still needs auth (skip with `--no-connect`). An existing GitHub `origin` links through the Valkor GitHub App. Any other existing `origin` is registered as-is. No `origin` creates a managed Valkor git repo. | Option | Effect | | --- | --- | | `--name ` | Display name for a new project. | | `--account ` | Account to create the project under (first ship only). | | `--origin ` | Override the inferred origin choice. | | `--github-token ` | Link a GitHub origin with this token instead of the GitHub App. | | `-m, --message ` | Commit message. | | `--no-commit` / `--no-verify` / `--no-env` / `--no-connect` | Skip that step. | | `-y, --yes` | Don't prompt. | | `-n, --dry-run` | Print what would happen; change nothing. | | `--project ` / `--host ` | Target a non-default project or host. | ### Sessions Each session runs in one sandbox on its own branch. | Command | Effect | | --- | --- | | `valkor sessions ls` | List every session on the project. | | `valkor sessions status [--all] [--json]` | Every session and what its agent is doing right now. Aliases: `overview`, `ps`. | | `valkor sessions info [--json]` | Detail view: status, branch, agent, sandbox URL. | | `valkor sessions new [--prompt ""] [--agent ] [--model ] [--wait] [--connect] [--json]` | Start a session. `--connect` attaches the OpenCode TUI once it is ready (implies `--wait`); on an interactive terminal without it, the CLI asks whether to connect after creation. `--model ` overrides the project's default model. `--wait` blocks until it is running (up to ~5 minutes). Use `--secret ` or `--no-secrets` to narrow Secret access. These Secret flags require a backend token. Use `--connector =` or `--no-connectors` to set Connector access. Use `--require-connector ` to require an authorization before provisioning. Scope flags are repeatable. Use `--context =` for non-secret runtime context. | | `valkor sessions chat [] [--prompt ""] [--queue] [--new] [--agent ] [--json]` | Talk to a session's agent. Interactive by default. Alias: `talk`. Top-level `valkor chat` also works. `--queue` is one-shot only: it stores the prompt in the session's durable inbox and returns as soon as it is stored, instead of handing it to the runtime. | | `valkor sessions connect [] [-- ]` | Attach the OpenCode TUI to the session's OpenCode server. Also available top-level: `valkor connect` / `valkor attach`. With no id, opens a session picker (running, stopped-with-restart, or new). The CLI auto-downloads the version-matched `opencode` binary (cache: `~/.valkor/opencode//`; override: `VALKOR_OPENCODE_BIN`). | | `valkor sessions shell [] [--new]` | Open a raw interactive terminal in the sandbox, with no agent. Reattaches to the session's existing terminal; `--new` always starts a fresh one. Aliases: `terminal`, `ssh`. | | `valkor sessions shell ls [--json]` | List the session's terminals: id, status, command. Needs no TTY. | | `valkor sessions shell kill ` | Kill one terminal. The ambient shell respawns on the next attach; anything running inside it does not. | | `valkor sessions log [] [--limit ] [--json]` | Print recent messages, read-only. Aliases: `messages`, `history`. | | `valkor sessions pending [--json]` | List open tool-permission or question prompts. Alias: `prompts`. | | `valkor sessions approve [] [--always] [--reject] [--message ""]` | Answer a permission prompt. | | `valkor sessions answer [] [--option ]... [--text ""] [--reject]` | Answer a question prompt. | | `valkor sessions digest [--since <7d>] [--json]` | Compact multi-session review. Aliases: `review`, `summary`. | | `valkor sessions scope [scope options] [--json]` | Read or replace Secret and Connector access. Alias: `access`. Use `--secret `, `--no-secrets`, or `--inherit-secrets` for Secrets. Use `--connector =` or `--no-connectors` for Connector bindings. Use `--require-connector ` or `--no-required-connectors` for required Connectors. Provided categories replace their current values. Omitted categories remain unchanged. Changes apply to the next prompt. Removed Secret values remain in existing context if the session already read them. | | `valkor sessions preview [port] [--port ] [--list] [--json]` | Print a clickable preview URL for a sandbox port. Default port: `3000`. `--list` prints the named candidates instead. | | `valkor sessions restart ` | Restart the session's sandbox. | | `valkor sessions rename ` | Set a session's name. Pass `""` to clear it. | | `valkor sessions rm ...` | Stop and delete one or more sessions. | | `valkor sessions open ` | Open a session's dashboard page in your browser. | | `valkor sessions stop [--json]` | Pause a session. The sandbox stops in place and the disk is kept. Alias: `pause`. Needs `project.session.stop`. | | `valkor sessions start [--wait] [--json]` | Wake a session: provision a missing sandbox, resume a stopped one, and resolve its runtime. Idempotent. Alias: `wake`. `--wait` blocks until ready (up to ~5 min) and exits `1` if the session ends up failed or stopped. | | `valkor sessions warm [--exclude ] [--json]` | Pre-create the session you are about to use, so the sandbox is already up. Reuses an existing unused warm session. A warm session stays hidden from `sessions ls` until its first prompt. | | `valkor sessions model [--json]` | Change the model a session runs, mid-session. A live sandbox is re-pointed and its runtime restarts, which ends the turn running right now; a stopped session stores the value for its next start. | | `valkor sessions compact [--json]` | Summarize the conversation so far and continue from the summary. | | `valkor sessions queue [ls] [--json]` | List the prompts still waiting in the session's durable inbox. | | `valkor sessions queue rm ` | Drop one queued prompt. Refused (`409`) once a model step has started answering it. | | `valkor sessions queue now ` | Run one queued prompt next: re-queue it ahead of the ordering rule and release the session's hold. | | `valkor sessions queue hold\|release` | Hold every queued prompt — what the Stop button writes — or release the hold. | | `valkor sessions approvals [ls] [--json]` | List the governed connector calls this session is waiting on a human for. | | `valkor sessions approvals approve\|deny ` | Let one governed connector call run, or refuse it. The agent is told and continues without it. | | `valkor sessions files [--json]` | Read and edit the sandbox's live workspace: `ls []`, `status`, `find `, `write `, `touch `, `mkdir `, `mv `, `rm `. | | `valkor sessions share [--mode private\|project\|members] [--member ] [--group ] [--show] [--json]` | Set who inside Valkor can open this session. With no `--mode` it prints the current setting and changes nothing. `--member` and `--group` are repeatable. | | `valkor sessions links ls [--json]` | List every public link ever minted on the session, newest first. | | `valkor sessions links create [options]` | Mint one public, unauthenticated link onto a preview port or one workspace file. | | `valkor sessions links revoke ` | Kill one public link. | `sessions queue` needs `project.session.start` — the same permission as sending a message. A queued prompt survives a closed terminal and is delivered when the session can take it. Put one there with `valkor sessions chat -p "…" --queue`. `sessions approvals` are durable: unlike `sessions pending`, they survive a sandbox restart. It needs `project.members.manage`, or being the human who launched the session. An agent may never resolve its own approval. `sessions files` reads the working tree the agent is editing right now, before anything is committed; `valkor files` reads the committed repo instead. Paths resolve under `/workspace` unless they start with `/workspace`, `/tmp`, `/home`, or `/opt`. The command wakes the sandbox if it is asleep. Options: `--from ` (`write` reads this file instead of stdin), `--content` (`find` greps contents with ripgrep instead of filenames), `--limit ` (`find` filename cap), and `-y` to skip the `rm` confirmation. `sessions share` is owner-governed: the API refuses a project manager who cannot already read the session. `sessions links create` options: `--port ` (default `3000`; `22`, `8000`, and the opencode ports are refused), `--path

` (default `/`), `--preview ` (a named candidate — `web`, `vite`, `dev-server`, `api-docs` — instead of `--port`/`--path`), `--file ` (share one workspace file instead of a preview; always read-only), `--mode view\|interactive` (default `view`; `interactive` allows writes and websockets, and is ignored for `--file`), `--label `, and `--expires `. Minting a link needs the session owner, because the link itself needs no login; listing and revoking also accept a project manager. Inside a sandbox, `VALKOR_SESSION_ID` is your own session's id. ### Change requests A change request (CR) merges one branch into another on any git host. It is the only way for an agent to land session work on the default branch. See [Change requests](/docs/work/change-requests). | Command | Effect | | --- | --- | | `valkor cr ls [--status open\|merged\|closed\|all] [--project ]` | List CRs. Default: `--status open`. | | `valkor cr show [--project ]` | Show one CR, including its merge preview. Alias: `info`. | | `valkor cr diff [--no-color] [--json]` | Print a CR's unified diff. | | `valkor cr open --title "" [--description ""] [--head ] [--session ] [--base ]` | Open a CR. Aliases: `new`, `create`. Inside a sandbox, `--head` and `--session` default automatically. `--base` defaults to the project's default branch. | | `valkor cr merge [--message ""]` | Merge an open CR. Fast-forward when possible, three-way merge otherwise. | | `valkor cr close ` | Close an open CR without merging. | | `valkor cr reopen ` | Reopen a closed CR. Merged CRs are terminal. | | `valkor cr merge-preview [--json]` | Report whether the CR can merge, and list every conflicting path. Alias: `preview`. | | `valkor cr request-changes --message ""` | Ask the agent that opened the CR to revise it. Alias: `changes`. | | `valkor cr version-diff --from --into [--json]` | Summarize one version against another before opening a CR. | `request-changes` records the note on the CR and delivers it to the originating session, booting its sandbox if it is asleep. It needs `project.review.act` — the same leaf the Review Center uses, not `gitops.push`. `` accepts a per-project number (`3`) or the full id. Inside a sandbox, the CLI reads its token automatically — no login or link needed. ### Review The project's review inbox — everything waiting on a human decision: change requests, connector tool calls a policy gated for approval, and the outputs, decisions, and batches agents submit for sign-off. Mirrors the dashboard's Review Center. Gated by the `review_center` feature flag; turn it on with `valkor projects features enable review_center`. | Command | Effect | | --- | --- | | `valkor review ls [--segment ] [--kind ] [--json]` | List inbox items. Default: every segment. | | `valkor review show [--json]` | Show one item in full. | | `valkor review act [--message ]` | Decide one item. `--message` carries the note. | | `valkor review bulk [ …]` | Decide several native items in one call. | | `valkor review submit --kind --title [options]` | Submit an output, decision, or batch for review. | Verdicts: `approve`, `reject`, `changes`, `answer`, `dismiss`. Segments: `needs_you`, `waiting`, `done`. Kinds: `change`, `approval`, `output`, `decision`, `batch`. Where a verdict lands depends on the item id. On `cr:`, `approve` merges the change, `reject` closes it, and `changes` sends the note back to the agent that opened it (`--message` required). On `call:`, `approve` lets the tool call run and `reject` denies it; a connector approval takes no other verdict — read its arguments first with `valkor review show`. Every other id goes to the native act endpoint, which takes every verdict. `bulk` acts on native ids only. A connector approval needs its own parameter review and a change request needs its diff in view, so both are reported and skipped — the same rule as the dashboard's multi-select. `submit` options: `--kind output\|decision\|batch` (required), `--title ` (required), `--summary `, `--risk none\|low\|medium\|high` (default `none`), `--detail ` (a JSON object), `--agent `, and `--session ` (ignored when it is not this project's session). Reads need `project.review.read`, verdicts need `project.review.act`, and `submit` needs `project.review.submit`. ### Secrets Encrypted values stored on the project. By default a secret injects as a plain environment variable into every session sandbox at boot (environment exposure). Enforced delivery — where the sandbox holds a handle and Valkor substitutes the real value outside it (egress-enforced exposure, and `valkor secrets call`) — is experimental. Enable the `secrets_egress` feature flag (Settings → Feature flags) to use it; with the flag off, `valkor secrets delivery … egress` returns `403` `feature_disabled`. See [Secrets](/docs/project/secrets). | Command | Effect | | --- | --- | | `valkor secrets ls` | List secrets by identifier and manifest `env` spec. Marks required-but-missing values. | | `valkor secrets set NAME=VALUE ... [--identifier ]` | Upsert one or more secrets. `NAME=-` reads the value from stdin. | | `valkor secrets request NAME ... [--scope runtime\|connector] [--expires ]` | Mint a link for a human to enter a value directly — you never see the raw value. | | `valkor secrets unset NAME ...` | Remove secrets. | | `valkor secrets grant IDENTIFIER --agent ` | Let one agent receive this secret: merge the identifier into that agent's `secrets` list in `valkor.yaml`, adding the agent entry when the manifest omits it. | `grant` is the fix for a row `ls` reports as undeliverable. It only ever widens one agent's list; to narrow or replace it, rewrite the whole set with `valkor agents scope`. There is no `secrets revoke` — the API has no route that removes a single identifier from a grant. The first grant on a project with no agents starts governance: from then on, an agent the manifest does not list receives no project secrets, and the command says so when it happens. ### Env | Command | Effect | | --- | --- | | `valkor env pull [--out ] [--force]` | Write a `.env` skeleton — names only. Values never leave the cloud. | | `valkor env push --from ` | Upload every `NAME=VALUE` from a dotenv file as a secret. | ### Agents Per-agent model settings on the linked project. | Command | Effect | | --- | --- | | `valkor agents ls [--json]` | Show every agent's pinned model and the fallback default. Alias: `models`. | | `valkor agents model ` | Pin an agent to a model. | | `valkor agents model --clear` | Clear the pin — the agent follows the default again. | | `valkor agents default ` | Make this the project's default agent. | | `valkor agents default --show [--json]` | Print the current default agent. | | `valkor agents scope [--secrets all\|none\|A,B] [--connectors all\|none\|a,b] [--require-connector ]` | Replace which secrets and connectors the agent may use. `--require-connector` is repeatable and must resolve before a session starts. | | `valkor agents scope --show [--json]` | Print the agent's current scope. | | `valkor agents config [--json]` | Print the full agent config block. | | `valkor agents config --file ` | Replace the block with a JSON file's contents. `-` reads stdin. | | `valkor agents config --set = ...` | Change single dotted keys, merged in. Repeatable, e.g. `opencode.model=glm-5.2`, `enabled=false`, `connectors=["slack"]`. | Every scope option replaces; none merge. A `--set` value is parsed as JSON when it parses, and kept as a string otherwise. Model pins and `scope` apply instantly, with no `valkor.yaml` commit; `default` and `config` commit to `valkor.yaml` on the project's default branch. `scope` needs `project.agent.write`; `default` and `config` need `project.customize.write`. ### Models Which models the project offers, and which one it starts with. Same surface as the dashboard's Customize → Models. A project stores only its exceptions to the catalog default (the newest model of each family). Enablement is display-only: it decides what pickers offer, never what the gateway serves. | Command | Effect | | --- | --- | | `valkor models ls [--json]` | List every model: state, origin, provider. | | `valkor models enable ...` | Offer these models. | | `valkor models disable ...` | Stop offering them. The project default refuses with `409` — change the default first. | | `valkor models reset` | Drop every exception; back to the catalog default. | | `valkor models default [--json]` | Print the default chain (project → account → platform) and what it resolves to. | | `valkor models default [--account]` | Set the project default, or the account-wide one with `--account`. | | `valkor models default --clear [--account]` | Clear the project, or account, default. | Model ids are gateway wire ids — a bare managed id (`glm-5.2`) or a BYOK `provider/model`. Copy one from `valkor models ls --json`. Per-agent pins live on `valkor agents model `. Writes need `project.customize.write`. ### Channels Manages the project's connection to a chat platform. Tokens are stored encrypted in the project's secrets and resolved server-side — they are never injected into the sandbox. | Command | Effect | | --- | --- | | `valkor channels status [--json]` | Show the current connection. | | `valkor channels connect [--wait] [--timeout ]` | Connect in one step: prints an install link. `--wait` polls until the install lands. | | `valkor channels connect --manual [--bot-token ] [--signing-secret ]` | Bring-your-own-app mode: save a bot token and signing secret directly. | | `valkor channels disconnect [--platform slack\|teams]` | Drop the project's connection — the Slack one, or the Teams one with `--platform teams`. | | `valkor channels manifest` | Print the app manifest JSON for the bring-your-own-app path. | | `valkor channels email status [--json]` | Inbox and delivery mode for one email connector. | | `valkor channels email connect [options]` | Create a managed inbox, or attach an existing AgentMail one. | | `valkor channels email disconnect` | Drop the inbox connection. | | `valkor channels email policy [--allow ] [--allow-regex ] [--allow-all]` | Replace who may email the agent. | | `valkor channels bindings [ls] [--json]` | List every bound channel and the agent, model, and join policy it resolves to. | | `valkor channels bind [--agent \|--no-agent] [--model \|--no-model] [--policy

]` | Change one binding. `--policy` takes `owner_approval`, `owner_only`, or `project_open`. | | `valkor channels voice name ` | Set the display name the voice bot joins calls with. | | `valkor channels voice name --show` | Print the current voice bot name. | `--platform slack|teams` selects the platform; default `slack`. Teams `connect` prints the Microsoft admin-consent URL; granting tenant-wide consent publishes the app to your Teams catalog automatically. See [Connectors](/docs/connect/connectors). The email channel is AgentMail-backed and needs the `agentmail_email` feature flag. `email connect` options: `--connector ` (default `valkor_email`), `--api-key ` (bring your own AgentMail key; `-` reads stdin), `--display-name ` (from-name on outgoing mail; default the project name), `--username ` and `--domain ` (a new managed inbox), and `--inbox-id ` with `--email ` (attach an existing inbox — both are required together). `--allow` is repeatable and puts the policy in restricted mode; a bare value with no `@`, or one with a leading `@`, is read as a domain. `--allow-all` clears the list and accepts every sender again. Email and `bind` writes need `project.connector.write`. `voice name` needs `project.customize.write`. ### Connectors Connectors an agent calls as tools. `add`, `rm`, and `policy set` edit the local `valkor.yaml`; run `valkor ship` to apply, unless you pass `--apply` to change the cloud project immediately. | Command | Effect | | --- | --- | | `valkor connectors ls [--json]` | List connectors and their status. | | `valkor connectors show [--json]` | Show one connector's tools. | | `valkor connectors add --provider

[options] [--apply]` | Add a connector. | | `valkor connectors rm [--apply]` | Remove a connector. | | `valkor connectors rename ` | Set a connector's display name. | | `valkor connectors sync` | Reconcile the catalog from the shipped `valkor.yaml`. | | `valkor connectors credential [value]` | Set a connector's credential. | | `valkor connectors connect ` | Start a one-click connect flow. | | `valkor connectors link [--expires ]` | Mint a shareable connect link for a human. | | `valkor connectors apps [] [--category ] [--cursor ] [--json]` | Browse the Pipedream app catalog. | | `valkor connectors catalog [] [--cursor ] [--json]` | Browse the direct-connector catalogue. Needs the `connectors_api_discover` flag. | | `valkor connectors catalog show [--json]` | Show one catalogue record's surfaces. | | `valkor connectors sensitive on\|off` | Gate this connector's reads too — every call then needs approval. Applies now. | | `valkor connectors owner project\|user` | Who authorizes: one project connection, or each member's own. Applies now. | | `valkor connectors machines [--show] [--add ] [--rm ]` | Which paired computers a `computer` connector may target. Applies now. | | `valkor connectors authorize [--status] [--scope ""] [--client-id ] [--client-secret ] [--success-redirect ] [--error-redirect ] [--json]` | OAuth 2.1 a connector end to end: discover the server's authorization metadata, register Valkor as a client (RFC 7591) where the server supports it, and print the URL to approve. `--status` reports the result instead. | | `valkor connectors authorize --device` | Same, using the OAuth 2.0 device flow (RFC 8628): print a code and a URL, then poll until it is approved, denied, or expired. | | `valkor connectors policy ls [--json]` | Show project-wide execution policy. Alias: `show`. | | `valkor connectors policy set --default [--apply]` | Set the default execution mode in `valkor.yaml`. `--apply` sets it live instead. | | `valkor connectors policy add [--condition ]` | Add a project-wide rule. Applies now. `--condition` narrows it to a matching argument and is repeatable; `k!=v` negates, and `k` is a dot path into the call's arguments. | | `valkor connectors policy rm ` | Remove a project-wide rule. Applies now. | | `valkor connectors policy ls\|set \|rm \|clear` | Manage one connector's tool-call rules. | `policy ls`, `show`, `set`, `add`, and `rm` are the project-wide surface, so a connector named after one of those verbs must be addressed as `policy ls`. A `` is a tool name, a glob (`send_*`), or a `/regex/`. `add` options: `--name

) [options]` | Create a custom template and start a build. | | `valkor sandboxes update [options]` | Update a template. | | `valkor sandboxes build ` | Trigger a rebuild. | | `valkor sandboxes rebuild ` | Force-rebuild: delete the existing snapshot first. | | `valkor sandboxes rm ` | Delete a template. | | `valkor sandboxes fix` | Start a session seeded with the last failed build log, to repair it. | | `valkor sandboxes provider [--json]` | Show the project's sandbox-provider pin and which providers this host offers. | | `valkor sandboxes provider [--timeout ]` | Pin every new session to one provider. Where the target needs its snapshot built first, the API answers with a preparation and the command follows it to completion. Default `--timeout`: 600s. | | `valkor sandboxes provider --clear` | Drop the pin and follow the platform default. Alias: `--unpin`. | | `valkor sandboxes provider status [--json]` | Show the latest provider transition and its history. Alias: `transition`. | Pinning a provider needs `project.customize.write`. `add`/`update` options: `--name

]` | List commits on `--ref`. | | `valkor files show ` | Show one commit and its changed files. | | `valkor files diff [--path

]` | Print a commit's unified patch. | | `valkor files compare ` | Summarize the diff between two refs. | | `valkor files download -o ` | Download the repo, or the `--path` subtree, at `--ref` as a zip. Alias: `archive`. | Options on every subcommand: `--ref `, `--path

`, `--limit `, `--json`. `download` also takes `-o, --out `, which is required. Every subcommand needs `project.file.read`. `download` additionally refuses any subtree that would include an agent or skill you are scoped out of — a zip cannot be filtered mid-stream — so archive a narrower `--path` in that case. ### Triggers A trigger starts a session from a schedule, a webhook, or a monitor (experimental). `add`, `rm`, `enable`, `disable` edit the local manifest — run `valkor ship` to apply. `pause`/`resume` flip a separate, server-side switch. See [Triggers](/docs/connect/triggers). | Command | Effect | | --- | --- | | `valkor triggers ls [--json]` | List triggers and their runtime state. | | `valkor triggers add [options] [--apply]` | Append a trigger to the manifest. `--apply` creates it on the cloud project now instead: it commits to `valkor.yaml` on `main` and reconciles. | | `valkor triggers set [options]` | Change a live trigger. Only the flags you pass are written. Always applies now — there is no local form. Alias: `update`. | | `valkor triggers rm [--apply]` | Remove a trigger from `valkor.yaml`, or from the cloud project now with `--apply`. | | `valkor triggers info [--json]` | Show one trigger. | | `valkor triggers fire ` | Fire a trigger manually. | | `valkor triggers enable [--apply]` / `disable [--apply]` | Turn one trigger on or off. | | `valkor triggers pause` / `resume` | Deactivate or reactivate every trigger on the project, server-side. | `add` options: `--type ` (default `cron`), `--prompt ` (required), `--agent `, `--cron ` (6-field, e.g. `"0 0 9 * * 1-5"`), `--run-at ` (run once at this instant instead of on a cron), `--timezone ` (default UTC), `--secret-env `, `--name

]` | Create one. The signing secret prints once, and a test delivery fires immediately. `--action-prefix` delivers only actions with that prefix. | | `valkor audit webhooks enable ` | Resume delivery. | | `valkor audit webhooks disable ` | Pause delivery, keeping the endpoint. | | `valkor audit webhooks rm ` | Delete a webhook permanently. | Audit webhooks stream the trail to a SIEM. Every verb needs `account.write`; `add` and `enable` also need the enterprise entitlement. `disable` and `rm` never do. Filters: `--actor`, `--actor-type`, `--project`, `--session`, `--source`, `--phase`, `--outcome`, `--action`, `--resource-type`, `--request-id`, `--correlation-id`, `--query`, `--since`, `--until`, `--cursor`, and `--limit`. Account lists and exports require `audit.read` and the account's `auditAccess` entitlement. Project-wide lists require `project.members.manage` because they can include private-session metadata. Session reconstruction requires `project.session.read` and visibility of that session. ### Roles A role is a named set of permissions. System roles (`owner`, `admin`, `member` at account scope; `manager`, `member` at project scope; plus `agent-user`, the marker an object assignment carries) are read-only references. Custom roles are yours to create and edit, and need the enterprise `rbac` entitlement. | Command | Effect | | --- | --- | | `valkor roles ls [--json]` | List roles, system and custom. | | `valkor roles show [--json]` | Show one role's permissions and usage. | | `valkor roles permissions [--json]` | List one role's permissions. | | `valkor roles create --name [options]` | Create a custom role. | | `valkor roles edit [--name ] [--desc \|--no-desc]` | Rename or re-describe a custom role. Its key never changes. Needs `role.update`, and refuses a system role. | | `valkor roles set-actions --actions a,b` | Replace a custom role's permissions. | | `valkor roles rm ` | Delete a custom role. | | `valkor roles export [--project ] [--out ] [--format toml\|json]` | Dump roles and assignments to a file. | | `valkor roles import ` | Apply a roles and assignments file. | Bind a role to a principal with `valkor access grant`. The older `valkor roles assign` / `unassign` / `assignments` verbs still work and write the same table, but `valkor access` is the documented path. `valkor roles actions` is superseded by `valkor permissions ls`. A custom role only adds permissions. Valkor has no deny rule, so a role cannot withhold a permission from a manager. ### Permissions The permission catalog, as data. One row per leaf action, with the scope it is decided at, whether it is delegable, and what it implies. Roles are built from these keys — `valkor roles create --actions` and `valkor roles set-actions` take exactly them. Alias: `valkor perms`. | Command | Effect | | --- | --- | | `valkor permissions ls [--scope account\|project] [--area ] [--json]` | List the catalog. | | `valkor permissions show [--json]` | Show one action in full. | ### Grants Assigns one project object to a principal — an **object assignment**. Secrets and connectors live on agents, so assigning an agent to a person grants everything that agent declares. An agent is closed by default: a member reaches it only when an assignment names them or one of their groups. `valkor access grant --agent ` writes the same row. | Command | Effect | | --- | --- | | `valkor grants ls [--json]` | List object assignments, and which agents can be assigned. | | `valkor grants assign --to [--group]` | Assign an agent to a user, or to a group with `--group`. | | `valkor grants revoke ` | Revoke one object assignment. | ### Manifest validation | Command | Effect | | --- | --- | | `valkor validate [--file ] [--json] [--scopes]` | Validate the manifest against the canonical schema. Resolves `valkor.yaml` first, then `valkor.toml`. Exit codes: `0` valid, `1` errors, `2` file missing. | | `valkor schema [--version 1\|2] [--url]` | Print the manifest's JSON Schema. `--url` prints the schema URL instead. | See [Manifest reference](/docs/project/manifest). ### Self-host `valkor self-host` runs one Docker-based stack, identical on a laptop, a VPS, or a cloud VM. See [Self-hosting](/docs/host) and [Self-hosting architecture](/docs/host/architecture). | Command | Effect | | --- | --- | | `valkor self-host init` | Create or refresh the self-host config. Does not start the stack. | | `valkor self-host configure` | Interactive wizard for connections and update policy. | | `valkor self-host doctor` | Validate Docker tooling and the rendered config. | | `valkor self-host plan` | Validate the rendered Compose config; change nothing. | | `valkor self-host start` | Create config if needed, then start the stack. Aliases: `up`, `deploy`. | | `valkor self-host update [--tag \|--channel stable\|latest]` | Pull images for the configured channel or tag and recreate the stack. Alias: `upgrade`/`reconcile`. | | `valkor self-host rollback --release ` | Roll back to an explicit older version. | | `valkor self-host version` | Show the running version and channel. | | `valkor self-host restart` / `stop` | Restart or stop the stack. Alias for stop: `down`. | | `valkor self-host status` / `ps` | Show service status. | | `valkor self-host open` | Open the dashboard in your browser. | | `valkor self-host connect-github` | Connect a GitHub App for managed repos. | | `valkor self-host env ls [--show]` | Show persistent config values, masking secrets by default. | | `valkor self-host env set KEY=VALUE ...` | Set a value and restart only the services it affects. | | `valkor self-host env rotate KEY\|--all-generated` | Regenerate a rotatable, CLI-generated secret. | | `valkor self-host logs [service]` | Tail stack logs. | | `valkor self-host uninstall` | Stop the stack and delete this instance's containers, volumes, and config. | Common flags: `--instance ` (default `default`), `--domain `, `--tunnel cloudflare`, `--version`/`--tag`/`--release `, `--channel stable|latest` (default `stable`), `--auto-update on|off` (default `on`; forced off by `--local-images`), `--update-time ` / `--update-tz ` (auto-updater schedule), `--local-images` (run locally-built images; dev mode), `--enterprise-license` (unlock SSO/SCIM/RBAC/audit), `--admin-email `, `--no-restrict-account-creation` (let any signed-in user create new accounts/orgs; default is admin-only), `--restrict-account-creation` (re-enable the admin-only default), `--json`, `--yes`. ### Token scope Every token starts with `valkor_pat_`. A user token is scoped to every project on your accounts. A project token is scoped to one project and auto-injected into that project's sandboxes. See the full token-family reference at [Session runtime](/docs/work/runtime). ### Exit codes | Code | Meaning | | --- | --- | | `0` | Success. | | `1` | Operation failed. Diagnostics print to stderr. | | `2` | Bad flag, unknown subcommand, or missing required argument. | --- # Computer Tunnel Connect your machine through the permissioned Valkor Agent Tunnel. Canonical page: https://beeuni.ai/docs/connect/computers A computer is your own machine — laptop, desktop, or server — connected to Valkor through a permissioned reverse tunnel. A computer is not a sandbox. A sandbox is a disposable cloud machine that Valkor creates for a [session](/docs/work/sessions). A computer is a machine you already own, and it stays connected across sessions. ## Connect a machine 1. Add **Computer Tunnel** from the project's connector catalog. 2. Run the pairing command shown in the profile, for example `npx --yes @valkor/agent-tunnel@latest connect --api-url `. 3. Approve the connection in your browser. 4. Select the paired machine for the profile. ## Grant access You grant access per capability: filesystem, shell, or desktop. You can scope each capability to allowed paths, commands, or desktop features. A capability grant is a per-resource setting on one machine, not a Valkor role. It grants no permission the role verdict denies — see [Accounts & access](/docs/accounts#per-feature-access-settings). The agent gets only what you grant. A call to an ungranted capability creates a permission request. Open the machine inside its Computer Tunnel profile to approve or deny the request. An unrestricted shell grant can run any executable available to your user. An unrestricted filesystem grant can access any path allowed by the local Agent Tunnel config. Grant the smallest path, command, feature, and expiry that the task needs. ## How the agent reaches a computer Pairing adds a machine to your account fleet. It writes no assignment, so it grants no project access. Add **Computer Tunnel** from the project's [connector](/docs/connect/connectors) catalog, then select one or more paired machines for that profile. One profile can contain one machine or a set of machines. A machine set is not an account group — an account group is a principal in the role model, and these are machines. You can create multiple profiles with different or overlapping machine sets. Each profile has independent agent grants and tool policies. The `list_computers` tool returns only machines assigned to the active profile. Other tools accept an optional `computer` name or id from that result. The selector is optional when exactly one assigned machine is online. ```bash valkor connectors call studio-computers.list_computers '{}' valkor connectors call studio-computers.fs.read \ '{"computer":"MacBook-Pro-9.local","path":"/etc/hosts"}' ``` A computer authenticates with a machine-specific setup token stored locally. The API stores only its hash. Remote connections require HTTPS/WSS. Project credentials cannot call the raw tunnel API; they must pass the selected Computer Tunnel profile, connector grant, and tool policy. Per-machine filesystem, shell, and desktop access also lives in the tunnel permission layer. The connector policy and tunnel permission must both allow a call. The local agent enforces each tunnel permission again. Its configured allowed paths and commands are maximum access boundaries that a server grant cannot widen. Computer Use requires a separately installed local `cua-driver`. Agent Tunnel does not download, install, or update that executable. You configure Computer Tunnel profiles from the dashboard, not from `valkor.yaml`. Use the profile's Accounts tab to pair, select, inspect, rename, and remove machines. Use its Tools tab to configure connector policy. --- # Connectors How connectors and connections give agents scoped access to external tools. Canonical page: https://beeuni.ai/docs/connect/connectors A connector links a project to an external tool or service. The agent calls it as a tool. Valkor brokers each call, so the sandbox never holds the connector credential. You declare most connectors in `valkor.yaml`. See the [manifest reference](/docs/project/manifest) for every field. Valkor declares channel and computer connectors when you connect a chat platform or a machine. ## Connectors and connections A connector is the agent-facing reach package. It is not a role, and it holds no Valkor permission: an agent reaches a connector only when its manifest grant lists the slug and the role verdict allows the call. See [One vocabulary, two bindings](/docs/accounts#one-vocabulary-two-bindings). A connector contains: - a project-unique slug - a display name - a provider app - one authorization strategy - connector policies A connection is one connected account or credential for the connector. Every connection uses the connector's policies. The authorization strategy is: - `project` for connections available to eligible project members - `user` for a connection owned by the acting project member A service account is a principal, but it is not a person, so it cannot use a member's `user` connection. Multiple connectors can reference the same provider app. Use separate connectors when one app needs different policies. ## Providers A connector uses one provider type: - **pipedream** — managed OAuth for supported SaaS apps - **openapi**, **postman**, **graphql**, **http** — direct API connectors - **mcp** — a remote MCP server over HTTP or SSE - **channel** — a chat platform connection - **computer** — one permissioned connector profile for one connected machine See [Slack and channels](/docs/connect/slack) and [Computers](/docs/connect/computers) for the managed provider flows. ## Authentication and policy A connection authenticates with: - OAuth through Pipedream, a channel install, or a native OAuth2 grant - an API key or token entered through the dashboard or SDK Valkor encrypts connection data and resolves it server-side for each tool call. The agent requests an action. Valkor attaches the credential, checks the agent grant and connector-connection policy, calls the external API, and returns the result. Connector policies belong to the connector. A connection cannot override them. Project guardrails apply above connector-connection policies. By default, an unmatched connector action runs without approval. Set `policy.default_mode: risk` to require approval for unmatched write and destructive actions. Set `sensitive: true` to make `require_approval` the connector's unmatched-action default, including reads. Explicit project or connector-connection rules still apply first. ### Approve one governed call `require_approval` creates one decision for one connector call. The Connector returns `202 pending_approval` with `approval_url`, `approval_summary`, and `execution_id`. It does not keep an HTTP request open. Share `approval_url` with any teammate. The URL identifies the request but does not grant authority. The page requires a signed-in Valkor account. Valkor then verifies that the account can access and approve actions in the project. The approval page shows the redacted parameters that the connector will receive. Approve or deny the call once. Valkor sends the decision back into the session through a durable callback. An approval applies only to the exact request digest. A changed recipient, subject, body, channel, URL, or other parameter requires a new decision. Open the session's **Audit** panel to use the same parameter view. Historical entries remain read-only. There is no session-wide approval option. Use an explicit `always_run` policy only when a connector action must run unattended. ## Connect with OAuth ### Open the project's Connectors page Open the project. Select **Connectors**, then select the app. ### Select the connection scope Select **Project** for a shared project connection. Select **User** for a connection owned by the acting project member. ### Complete authorization Complete the OAuth flow. Valkor stores the connected account as a connection. ## Connect an MCP server that uses OAuth 2.1 Valkor implements the MCP authorization specification. Open the connector, select **Add credential**, then select the **OAuth 2.0** tab. Valkor probes the server and reads its metadata: 1. The unauthenticated probe returns `401` with `WWW-Authenticate: Bearer resource_metadata="…"`. 2. Valkor reads the protected resource metadata (RFC 9728) at that URL, or at `/.well-known/oauth-protected-resource`. 3. Valkor reads the authorization server metadata (RFC 8414 or OpenID Connect discovery) for the first authorization server the resource names. When that server advertises `registration_endpoint`, Valkor registers itself as an OAuth client (RFC 7591) and shows one button: **Connect <server>**. You create no application, and you copy no client ID or secret. Valkor then runs Authorization Code with PKCE (S256) and binds the token to the server with the `resource` parameter (RFC 8707). Register this redirect URI when a server needs one in advance: ```text https://api.valkor.com/v1/connectors/oauth2/callback ``` When the server publishes endpoints but no `registration_endpoint`, Valkor prefills the authorization URL, token URL, and scopes. Enter the client ID of an app you create with that provider. When the server publishes no metadata, enter every field. Valkor keeps the MCP session: it runs `initialize` and `notifications/initialized` on demand, then sends `Mcp-Session-Id` on later calls. A server that answers without a session never sees the handshake. Valkor binds each connection to the authorization server that issued it. When the callback carries an `iss` parameter (RFC 9207), Valkor rejects it unless it matches the recorded issuer — a code minted by a different server is refused before it is redeemed. ### Authorize from the CLI The dashboard is one way to run this flow, not the only one. Declare the connector in `valkor.yaml`, then authorize it from a terminal or an agent session: ```yaml connectors: - slug: read-ai name: Read AI provider: mcp url: 'https://api.read.ai/mcp' auth: type: bearer ``` ```text valkor connectors authorize read-ai --json ``` The command creates the connection, runs the discovery chain, registers Valkor as a client when the server supports RFC 7591, and returns the URL to approve: ```json { "connection_id": "7b1a16b2-...", "registered": true, "scopes": ["openid", "offline_access", "mcp:execute", "meeting:read"], "authorization_url": "https://authn.read.ai/oauth2/auth?response_type=code&...", "expires_at": "2026-08-19T14:48:26.345Z" } ``` An agent returns `authorization_url` to the person it is working with. After they approve, the agent confirms: ```text valkor connectors authorize read-ai --status ``` The command exits non-zero while the status is `error`. Use `--scope` to narrow what is requested. Use `--client-id` and `--client-secret` for a server that does not support dynamic client registration. The same steps are available on the SDK — `discoverConnectionOAuth2Resource`, `registerConnectionOAuth2Client`, `startConnectionOAuth2Authorization`, and `getConnectionOAuth2Status`. Valkor refetches the connector's tool catalog as soon as authorization completes, so the connector leaves the `error` state without a manual sync. ### Self-hosted: give the box a stable public URL The callback URL is derived from `VALKOR_URL`, the public origin of your API. Authorization servers compare `redirect_uri` byte for byte, so the value must be stable. A self-host install started with the zero-config quick tunnel gets a **new** `https://.trycloudflare.com` hostname every time `cloudflared` restarts. The callback URL changes with it. A server that supports dynamic client registration recovers on its own — the next authorization registers a new client against the current URL. A server that needs a pre-registered OAuth app does not: you must update its allowed redirect URI after every restart. Set `CLOUDFLARE_TUNNEL_TOKEN` and `CLOUDFLARE_TUNNEL_HOSTNAME` for a named tunnel, or point `VALKOR_URL` at your own domain. Then register one callback URL once: ```text https:///v1/connectors/oauth2/callback ``` ## Connect a direct API with OAuth2 Direct connectors support: - client credentials - authorization code with PKCE - device authorization - dynamic client registration (RFC 7591) For client credentials, enter the token URL, client ID, scopes, and client secret. You can use `client_secret_basic`, `client_secret_post`, `client_secret_jwt`, or `private_key_jwt` token-endpoint authentication. For authorization code, enter the authorization URL and token URL. For device authorization, enter the device-authorization URL and token URL. An RFC 8414 discovery URL can provide these endpoints. Valkor encrypts the OAuth2 configuration and tokens. It refreshes access tokens before expiry, and it stores each rotated refresh token. Revoking the connection blocks the next connector call. For Microsoft Graph, use: ```text https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token https://graph.microsoft.com/.default ``` For direct SharePoint REST calls, use the SharePoint resource scope: ```text https://{tenant}.sharepoint.com/.default ``` ## Connect with an API key ### Declare the connector ```yaml connectors: - slug: stripe-read name: Stripe read access provider: openapi spec: 'https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json' authorization_strategy: project auth: type: bearer policies: - match: 'get_*' action: always_run - match: '*' action: block ``` `authorization_strategy` defaults to `project` when the manifest omits it. ### Merge the change request Valkor reads the manifest from the default branch. The connector becomes active after the change request merges. ### Add the connection Open the connector and set its credential. Valkor stores the value encrypted. It does not write the value to the manifest. ## Grant an agent access Add the connector slug to the agent's `connectors` field: ```yaml agents: release-bot: connectors: [stripe-read] connectors_required: [stripe-read] ``` `connectors_required` must be a subset of `connectors`. A session for this agent returns `409 CONNECTOR_CONNECTION_REQUIRED` before sandbox startup when it cannot resolve a valid active connection. Omit `connectors`, and the agent gets `none`. Merge the change before it takes effect. ## Select a session connection Default resolution follows the connector's authorization strategy. A session can select a specific connection: ```json { "connector_bindings": { "stripe-read": { "connection_id": "00000000-0000-4000-8000-000000000000" } } } ``` The binding key is the connector slug. The value is an active connection that matches the connector's strategy. Use `GET /projects/{projectId}/sessions/{sessionId}/scope` to read the effective binding. Use `PUT` on the same path to replace it. The replacement applies to the next tool call without restarting the session. ## Use a connector in a session Inside a session, use the Connector CLI: ```text valkor connectors ls valkor connectors call stripe-read '' ``` `connectors` lists the connectors in scope. `call` runs one action. Slack and Microsoft Teams connect from the dashboard the same way as OAuth apps. Connecting Slack writes a `channel` connector to `valkor.yaml` for you. See [Slack & channels](/docs/connect/slack). --- # Connect & automate Connectors, Slack, computers, and triggers reach outside a project. Canonical page: https://beeuni.ai/docs/connect A project reaches outside itself four ways. A connector calls an external tool. A channel starts a session from chat. A computer connects the agent to your own machine. A trigger starts a session with no person present. - [Connectors](/docs/connect/connectors): Give the agent scoped, brokered access to external tools and APIs. - [Slack & channels](/docs/connect/slack): Start and continue a session from a Slack or Teams message. - [Computers](/docs/connect/computers): Connect your own machine to the agent through a permissioned tunnel. - [Triggers](/docs/connect/triggers): Start a session automatically, on a schedule or from a webhook. --- # Slack & channels Connect Slack to a project and control sessions from a channel. Canonical page: https://beeuni.ai/docs/connect/slack A channel connects a chat platform to a project. A message in a connected channel starts a [session](/docs/work/sessions). The agent replies in the same thread. ## Live channels Valkor supports four channel platforms today: - **Slack** — connect with one click through the Valkor-managed app, or bring your own bot token. - **Microsoft Teams** — connect through org admin-consent OAuth, or bring your own Azure Bot (experimental; enable it under [Settings → Experimental](/docs/feature-flags)). - **Email** — an AgentMail-backed channel (experimental; enable it under [Settings → Experimental](/docs/feature-flags)). - **Voice** — a realtime voice channel on LiveKit (experimental; enable it under [Settings → Experimental](/docs/feature-flags)). A connected call is a LiveKit room; the agent speaks through the live media session. Only Slack and Microsoft Teams connect through the CLI and dashboard. Email and voice are managed from the dashboard or SDK. This page covers Slack. Teams follows the same session, identity-linking, and credential rules. ## How a channel starts a session The first message in a Slack thread or Teams conversation creates a session. Every later message in that thread goes to the same session, even after the sandbox stops and resumes. A Slack workspace connected to more than one project shows a project picker on the first mention. ## Identity linking Valkor links each chat sender to a Valkor account before the agent runs for them. Run `/valkor login` in Slack and follow the link to sign in. Teams uses the same requirement. An unlinked sender gets a prompt to link instead of a session run. ## Where credentials live A connected channel's bot token is a connector-scoped [secret](/docs/project/secrets). It does not appear on the project's Secrets page. Valkor never injects it into a sandbox. Valkor resolves the token server-side when the agent sends a chat message. ## Connect Slack ### Start the connection From the CLI, run: ``` valkor channels connect ``` On a host with the shared Slack app configured (Valkor Cloud, for example), this prints a one-click install link. Open the link and pick the workspace. Click **Allow**. Add `--wait` to make the command poll until the install lands. From the dashboard, open the project's Channels page and connect Slack there instead. It uses the same install flow. ### Use your own Slack app On a self-hosted deployment with no shared Slack app configured, `valkor channels connect` falls back to manual mode automatically. Run `valkor channels manifest` to print an app manifest. Create the app at `api.slack.com/apps` from that manifest. Install the app to your workspace. Then run: ``` valkor channels connect --manual --bot-token xoxb-... --signing-secret ... ``` ### Check the connection ``` valkor channels status ``` Run `valkor channels disconnect` to remove the connection. See the [CLI reference](/docs/cli) for every flag. Connecting Slack adds a `channel` [connector](/docs/connect/connectors) entry to `valkor.yaml` for you. You never write this entry by hand. ## Use Slack Mention the bot with a task, in any channel it has joined, or open a direct message. The thread keeps its session as described above. A bare mention with no task gets a reminder to add one instead of starting a session. ## Control a session with slash commands Type these as `/valkor ` in Slack, or as plain text in a DM. Slash commands do not run inside the Assistant DM pane, so Valkor parses the same words from plain text there. | Command | What it does | |---|---| | `login`, `logout` | Link or unlink your Slack identity | | `switch`, `unbind` | Rebind or unbind this channel from its project | | `projects` | List projects you can bind to | | `sessions` | List recent sessions started in this workspace | | `whoami` | Show the channel panel: project, agent, model, policy, and your linked identity | | `agent `, `model ` | Set the agent or model this channel uses | | `policy ` | Set who can start sessions here | | `help` | List all commands | `policy` accepts `project_open` (default — any project member who mentions the bot gets a session), `owner_approval`, or `owner_only`. This is a per-resource setting on one channel, not a role. It grants no permission the role verdict denies — see [Accounts & access](/docs/accounts#per-feature-access-settings). ## What does not work - Slash commands do not fire inside the Assistant DM pane. Type `/valkor ` as plain text there instead. - Other chat platforms, including Telegram, are not supported channels today. Only Slack and Microsoft Teams connect through the CLI and dashboard; email and voice connect through the dashboard or SDK. --- # Triggers A trigger starts a session on a schedule, from a webhook, or from a monitor. Canonical page: https://beeuni.ai/docs/connect/triggers A trigger starts a [session](/docs/work/sessions) with no person present. Use a trigger to automate recurring or event-driven work. ## Trigger types Valkor supports three trigger types. - **cron** — runs on a schedule you set. - **webhook** — runs when an external service sends a signed request to the project's webhook URL. - **monitor** — runs a command from your repository 24/7. Each line the command prints to stdout fires the trigger. Experimental: see [Monitors](#monitors). You define triggers in the project manifest, `valkor.yaml`. Each trigger holds a prompt that renders as the fired session's first message. Runtime state — such as the last fire time and status — lives outside the manifest, in the database. Firing a trigger does not create a commit. Creating, updating, or deleting a trigger through the API, SDK, or dashboard writes directly to the default branch. It does not go through a [change request](/docs/work/change-requests) (CR). Editing `valkor.yaml` inside a session and running `valkor ship` follows the normal branch and CR flow instead. ## Set up a cron trigger ### Add the trigger ```sh valkor triggers add daily-digest --type cron \ --cron "0 0 9 * * 1-5" --timezone America/Los_Angeles \ --prompt "Summarize yesterday's activity and save it as a daily note." ``` `cron` is a 6-field expression: second, minute, hour, day, month, weekday. This command edits your local `valkor.yaml` only. ### Ship it ```sh valkor ship ``` `valkor ship` commits `valkor.yaml` and pushes it. The schedule goes live once this lands on your project's default branch. ### Confirm it runs ```sh valkor triggers ls ``` The list shows each trigger's slug, state, and when it last fired. To fire it now instead of waiting for the schedule, run `valkor triggers fire daily-digest`. ## Set up a webhook trigger A webhook trigger needs a secret. Valkor uses it to check the signature on every incoming request. ### Add the secret ```sh valkor secrets set WEBHOOK_SECRET= ``` See [Secrets](/docs/project/secrets) for more on secrets. ### Add the trigger ```sh valkor triggers add new-lead --type webhook \ --secret-env WEBHOOK_SECRET \ --prompt "A new lead arrived: {{ body.name }} ({{ body.email }}). Add it to the CRM." ``` `--secret-env` names the secret that signs requests to this trigger. ### Ship it ```sh valkor ship ``` ### Send it a request Valkor builds the webhook URL from your project id and the trigger's slug: ``` POST /v1/webhooks/projects// ``` Send a signed `POST` request to this URL from the external service. Valkor checks the signature against `WEBHOOK_SECRET`, then starts a session with the request body available in the prompt. See [Webhook signature](#webhook-signature) below for the exact header and format. By default, each fire starts a fresh session on a new branch. A trigger can instead reuse or pin a session, and a webhook trigger can filter which payloads start one — see [Session strategy](#session-strategy) and [Payload templating](#payload-templating) below. ## Monitors **Experimental.** Monitors run only where the `monitors` feature flag is on. The flag is off by default. While it is off, the platform provisions no monitor box and fires no monitor event. A monitor watches something that neither pushes webhooks nor fits a schedule: a live log, a queue depth, a page that changes, a price. Valkor runs your command 24/7 in the project's monitor box — one persistent microVM per project, with the same isolation boundary and the same project secrets a session sandbox gets. Deterministic code watches; the agent wakes only when the command emits a line. Four rules define the contract: - **Stdout lines are events. Nothing else is.** Stderr is diagnostics: visible in the monitor's logs, never fires. - Each line fires the trigger exactly once, through the path a webhook already uses: `filter` → prompt template → `session_mode`. - **A monitor cannot fail silently.** Process exit, restart-budget exhaustion, and silence longer than `expect_event_within` each fire a platform-written lifecycle event in the same stream. - `session_mode` defaults to `reuse` on a monitor, not `fresh`. A monitor fires repeatedly by design, so `fresh` would mint one session per event. ### Add the monitor ```sh valkor triggers add checkout-errors --type monitor \ --run "./monitors/checkout-errors.ts" \ --mode poll --interval 60s --expect-event-within 24h \ --prompt "Checkout monitor emitted: {{ line }}" ``` `--mode poll` re-runs the command every `--interval` and expects it to exit. `--mode stream` runs it once and keeps it alive; a stream takes no `--interval`. Both shapes produce lines, and nothing downstream can tell them apart. `cron`, `run_at`, `timezone`, and `secret_env` are rejected on a monitor. ### Ship it ```sh valkor ship ``` The platform starts the monitor box once this lands on the default branch. ### Confirm it runs ```sh valkor triggers ls valkor triggers info checkout-errors ``` `ls` shows a monitor's mode and interval where a cron shows its schedule. `info` shows `run`, `mode`, `interval`, and `expect_event_within`. ### Monitor limits Every bound below is enforced by the platform. | Bound | Value | | --- | --- | | Monitors per project | 10 enabled | | Poll interval | at least 30s | | `expect_event_within` | at least 5m | | Event rate per monitor | 60/hour sustained, burst 30. Overflow suppresses the monitor for 10 minutes; 3 suppressions in 24 hours disables it. | | Line length | 8 KiB, truncated with a `truncated: true` marker | | Restart budget | 5 restarts / 10 minutes, then a `restart_budget_exhausted` lifecycle event and 15-minute backoff | | Event retention | 30 days | | Monthly box budget | $75 by default. Past it, the box stops and one `budget_exceeded` lifecycle event fires. | The monitor box needs a provider that supports a persistent sandbox. Where the project's provider cannot hold one, the `monitors` flag reports itself unavailable. ## Config shape ```yaml # valkor.yaml triggers: - slug: daily-digest # required, lowercase + dashes, unique per project name: Daily digest # optional, defaults to slug type: cron # "cron" | "webhook" | "monitor", required agent: valkor # optional, defaults to "default" model: anthropic/claude-sonnet-4-5 # optional, resolves at fire time if unset enabled: true # optional, default true cron: "0 0 9 * * 1-5" # 6-field expression, mutually exclusive with run_at timezone: America/Los_Angeles # IANA name, default UTC session_mode: reuse # "fresh" | "reuse" | "pinned" | "keyed", default "fresh" filter: # optional, webhook payload guard "body.data.direction": "inbound" prompt: "Summarize {{ body.text }}" # required, template string ``` A monitor replaces the schedule fields with its command and shape: ```yaml # valkor.yaml triggers: - slug: checkout-errors type: monitor run: ./monitors/checkout-errors.ts # required, repo-relative command mode: poll # "poll" | "stream", required interval: 60s # required on poll, invalid on stream expect_event_within: 24h # optional silence watchdog agent: oncall session_mode: reuse # the monitor default filter: # optional, same guard a webhook uses "line.severity": "error" prompt: "Checkout monitor emitted: {{ line }}" ``` Legacy `valkor.toml` uses the same fields in a different container; see [legacy TOML](/docs/project/legacy-toml). ## Fields | Field | Required | Default | Notes | | --- | --- | --- | --- | | `slug` | yes | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique per project. | | `type` | yes | — | `cron`, `webhook`, or `monitor`. | | `prompt` | yes | — | Template string. Renders as the session's first message. | | `name` | no | `slug` | Human label. | | `agent` | no | `default_agent` | Must name a key in `agents:`. Omit it to use `default_agent`; do not write the literal `default`. | | `model` | no | resolves at fire time | Wire form `provider/model`, for example `anthropic/claude-sonnet-4-5`. | | `enabled` | no | `true` | When `false`, the scheduler and the webhook receiver skip the entry. | | `session_mode` | no | `fresh`, or `reuse` on a monitor | See [Session strategy](#session-strategy). | | `session_id` | required for `pinned` | — | Exact session to re-prompt. | | `session_key` | required for `keyed` | — | Template string. Setting it alone implies `session_mode: keyed`. | | `filter` | no | — | Dotted path → expected string. A webhook delivery that does not match returns `200` and fires no session. | | `cron` | one of `cron`/`run_at`, on `type: cron` | — | 6-field expression: second minute hour day month weekday. | | `run_at` | one of `cron`/`run_at`, on `type: cron` | — | ISO-8601 timestamp. Fires once, then stays dormant. | | `timezone` | no, cron only | `UTC` | IANA name. | | `secret_env` | required, webhook only | — | Name of a project secret holding the signing key. The secret must use `broker` delivery with the `connector` consumer. | | `run` | required, monitor only | — | Repo-relative command whose stdout lines are the events. One line, at most 1024 characters. | | `mode` | required, monitor only | — | `poll` re-runs `run` every `interval`; `stream` runs it once and keeps it alive. | | `interval` | required for `mode: poll` | — | Duration literal (`30s`, `5m`, `24h`, `7d`), minimum `30s`. Invalid on `mode: stream`. | | `expect_event_within` | no, monitor only | — | Duration literal, minimum `5m`. Silence longer than this fires a lifecycle event. | A cron trigger needs `cron` or `run_at`, never both. A webhook trigger without `secret_env` is rejected — there is no unauthenticated webhook. A monitor needs `run` and `mode`, and rejects `cron`, `run_at`, `timezone`, and `secret_env` outright — a manifest that claims a schedule the monitor runner never reads is a lie. Configure the signing secret before you create or update a webhook trigger: ```bash valkor secrets set WEBHOOK_SECRET=- valkor secrets delivery WEBHOOK_SECRET broker --consumer connector ``` Pass the value to the first command on standard input. If the secret previously used sandbox delivery, rotate it after the delivery change because an existing sandbox can retain the previous value. ## Session strategy `session_mode` controls which session a fire re-prompts. Valkor tries the modes below in order and falls through on failure at each step. 1. **`pinned`** — re-prompt the exact `session_id`. If that session is gone or failed, fall through. 2. **`keyed`** — render `session_key` against the payload, then look up the most recent non-failed session previously stamped with that exact key. If the key renders empty, or no session matches, fall through to a fresh session. It never falls through to another key's session. 3. **`reuse`** — re-prompt the most recent non-failed session this trigger previously created. A pinned trigger falls back here too, before falling further. 4. **`fresh`** — create a new sandbox and branch. This is the default, and the final fallback for every mode. The new session becomes the trigger's session for future `reuse` and `keyed` fires. ## Session access Sessions a trigger creates use the private policy by default. The trigger agent's service account owns them — an agent's identity is a `service_account` principal, so it can own a session and hold assignments like any other principal. A project manager can always open them. An account owner and an account admin hold manager-equivalent access on every project, so the same applies to them. The person who configured or manually fired the trigger does not gain access through that action unless they hold one of those roles. Trigger settings offer three policies: - **Trigger agent and project managers** — no ordinary project member can open the session. - **Selected teammates** — the trigger agent, project managers, and selected project members or account groups. - **Whole project** — every project member. This policy 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). The access policy is account-local runtime state. It does not enter the portable `valkor.yaml` manifest because principal ids belong to one account. Use the dashboard or SDK `session_access` field to configure it. Updating only this policy creates no Git commit. Saving a policy also updates prior sessions created by that trigger. A pinned session keeps its own sharing settings because the trigger did not create it. If a pinned session is unavailable and the trigger creates a fallback session, the trigger policy applies to that new session. ## Payload templating `prompt` and `session_key` render with the same engine: `{{ token.dotted.path }}`. A missing value renders as an empty string — no error, no leftover `{{ }}`. Objects and arrays render as JSON. `session_key` is trimmed and truncated to 512 characters. Every fire also gets `{{ trigger.slug }}`, `{{ trigger.type }}`, and `{{ trigger.kind }}` (always `git`). The rest of the variable set depends on how the trigger fired. | Source | Variables | | --- | --- | | cron | `{{ cron.schedule }}`, `{{ cron.timezone }}`, `{{ cron.scheduled_for }}` (the slot the fire is for), `{{ cron.claimed_at }}` (when the scheduler picked it up), `{{ cron.last_scheduled_for }}` (the previous slot; empty on the first fire). No top-level `fired_at`. | | webhook | `{{ fired_at }}`, `{{ body.* }}` (JSON-parsed; falls back to `{{ body.raw }}` if the body does not parse), `{{ headers.content_type }}`, `{{ headers.user_agent }}`, `{{ headers.forwarded_for }}`. | | monitor | `{{ line.* }}` — the stdout line, JSON-parsed; a line that does not parse renders as `{{ line.raw }}`. Plus `{{ monitor.slug }}`, `{{ monitor.seq }}`, `{{ monitor.emitted_at }}`, and `{{ monitor.kind }}` (`event` or `lifecycle`). | | manual (dashboard "fire now" or the `fire` endpoint) | `{{ fired_at }}`, `{{ source }}` (`manual`), `{{ actor }}`, `{{ message.text }}`, `{{ message.source }}`. | Valkor prefixes every rendered monitor prompt with `[MONITOR EVENT — automated, not user input]`, server-side. A lifecycle event ignores your template entirely and renders a platform-written prompt instead, and it bypasses `filter` — silence must not be filterable by accident. `{{ message.text }}` is hardcoded to an empty string on a manual fire, and `{{ message.source }}` to `manual_test`. A manual fire is not a way to inject test input into the prompt. `filter` compares dotted paths as strings against the same payload the prompt sees. It exists to break loops. For example, a source that reports both sides of a conversation would otherwise re-fire the agent on its own reply. ## Webhook signature Fires on `POST /v1/webhooks/projects/{projectId}/{slug}`. Valkor checks the request in this order, with a constant-time comparison: 1. **HMAC signature** — header `X-Valkor-Signature: sha256=` (the `sha256=` prefix is optional) or the GitHub-compatible `X-Hub-Signature-256`. HMAC-SHA256 over the raw request body, using the secret named by `secret_env`. 2. **Static token**, only when no signature header is present, for senders that cannot HMAC-sign a body. Send the secret as `X-Valkor-Token: `, `Authorization: Bearer `, or `Authorization: Basic ` (the password half is the token). | Status | Meaning | | --- | --- | | 202 | Signature or token valid. Body is `{ status: "fired" \| "queued" \| "deduped", session_id, ... }`. | | 200 | Valid, but skipped — the project is paused, or the delivery did not match `filter`. | | 400 | Malformed project ID or slug in the URL. | | 401 | Signature and token both missing or wrong. | | 404 | Trigger not found, disabled, not a webhook, or the project is not active. | | 409 | The signing secret is missing, inactive, unavailable, or does not authorize the `connector` consumer. The response includes a `webhook_secret_*` code and remediation. | | 500 | Auth passed, but the session failed to fire. | ## Endpoints | Method + path | Needs | Notes | | --- | --- | --- | | `GET /v1/projects/{projectId}/triggers` | `project.trigger.read` | Lists triggers, runtime state, and manifest parse errors. A bad entry appears in `errors[]`; it does not break the other triggers. | | `POST /v1/projects/{projectId}/triggers` | `project.trigger.create` | Creates a trigger. Commits to the manifest directly. | | `PATCH /v1/projects/{projectId}/triggers/{slug}` | `project.trigger.update` | Partial update, merged onto the current entry. | | `DELETE /v1/projects/{projectId}/triggers/{slug}` | `project.trigger.delete` | Also clears the trigger's runtime state. | | `PATCH /v1/projects/{projectId}/triggers/activation` | `project.trigger.update` | Body `{ paused: boolean }`. See [Pause and resume](#pause-and-resume). | | `POST /v1/projects/{projectId}/triggers/{slug}/fire` | `project.trigger.fire` | Manual fire. The built-in project `member` role holds `project.trigger.fire`, so an ordinary member can fire a trigger. | | `POST /v1/webhooks/projects/{projectId}/{slug}` | signature or token | Public URL, gated by the webhook secret. | ## Pause and resume A project-level switch stops every trigger in the project at once, independent of each trigger's own `enabled` field. While paused, the scheduler skips the project and inbound webhooks return `200` with `{ status: "skipped" }` — no session fires. A manual fire still works. Use this when the same repository runs on two control planes (for example, dev and production) so cron does not fire twice. CLI: `valkor triggers pause` and `valkor triggers resume`. See [CLI](/docs/cli) for the full `valkor triggers` command group. ## Limits and reliability - The scheduler polls roughly every second (default 1,000 ms; configurable via `VALKOR_TRIGGER_SCHEDULER_INTERVAL_MS`). Cron precision is best-effort to the second, even though the expression has a seconds field. - Each project allows 3 triggered sessions provisioning at once, by default. The account's plan-tier active-session cap can also apply. A fire past either limit returns `queued` (`202`) instead of failing, and runs once a slot frees up. - A manual or webhook fire has a 45-second timeout. Loading the manifest has a 30-second timeout. - A cron fire is keyed on the due schedule slot, so a fire that timed out but actually landed does not duplicate on retry. A webhook fire is keyed on the delivery ID header, or a hash of the body and signature when the sender sends no ID. --- # Apps Deploy static sites, bundles, Dockerfiles, and OCI images to stable Valkor URLs. Canonical page: https://beeuni.ai/docs/feature-flags/apps A Valkor App is a provider-neutral serverless deployment owned by one project. An App owns one stable URL. Each deployment is immutable and numbered. A failed deployment never replaces live traffic. Apps is a [feature flag](/docs/feature-flags). Its stability is **stable**, and it is off by default. Turn it on per project before you deploy. Building on Apps from TypeScript? See [SDK → Apps](/docs/sdk/apps) for the client surface and the React hooks. ## Turn Apps on Open **Settings → Experimental** and switch **Apps** on for the project. You need `project.customize.write`. While the flag is off: - Every Apps route answers `403` with `{ error, code: "feature_disabled", feature: "apps" }`. - `valkor apps ` prints the same sentence and exits `1`. - The **Apps** entry does not appear in the project sidebar. Opening `/projects//apps` directly shows a gate screen that links to the flag. The page itself never enables the feature. ## Source kinds All four source kinds run on the same Valkor sandbox hosting backend. Valkor selects Daytona, Platinum, or E2B. They therefore share one deployment contract and one cold-wake contract. | Kind | Deploy this | Valkor does | |---|---|---| | `static` | Plain HTML, CSS, JS, or a prebuilt SPA or `dist/` | Serves the files | | `bundle` | A package source | Runs the install and build commands, then serves the output directory | | `dockerfile` | A repository with a Dockerfile | Builds the image, then runs your `command` on your `port` | | `oci_image` | A public image reference | Runs your `command` on your `port` | Pick the fastest path for the result you want: - Build Vite locally and deploy `dist/` as `static` for the lowest latency. - Deploy the package source as `bundle` when Valkor must run the install and build. - Export Next.js with `output: 'export'` and deploy `out/` as `static` when the App needs no server runtime. - Deploy server-rendered Next.js and arbitrary services as `dockerfile`, with an explicit command and port. - Deploy an existing public image as `oci_image`, with an explicit command and port. `dockerfile` and `oci_image` require `--command` and `--port`. `static` and `bundle` do not. ## Deploy from the CLI ```bash valkor apps deploy . ``` `deploy` creates the App on first use, registers an immutable artifact — uploading a `.tar.gz` for a path, or recording the reference for `--image` — builds it, and blocks until the stable URL is ready. The wait budget is `--wait-seconds`, default `1200`. Use `--no-wait` only when another process owns status tracking. ```bash valkor apps deploy dist --slug docs --access project valkor apps deploy . --type dockerfile --command '["node","server.js"]' --port 3000 valkor apps deploy --image ghcr.io/acme/service:2026-08-07 --command '["node","server.js"]' --port 3000 ``` The full subcommand list: | Command | What it does | |---|---| | `valkor apps list` | List the project's Apps. `--json`. | | `valkor apps create ` | Create an App without deploying it. | | `valkor apps deploy [path]` | Deploy a directory, a `.tar.gz`, or `--image`. | | `valkor apps set ` | Change an existing App: `--name`, `--cpu`, `--memory-gb`, `--disk-gb`, `--idle-timeout`, `--budget`. | | `valkor apps show ` | Show the App and its deployments. `--json`. | | `valkor apps logs [deployment]` | Read runtime logs. `--after N --limit N`. | | `valkor apps start ` | Permit requests and start the App. | | `valkor apps stop ` | Suspend compute now. | | `valkor apps rollback ` | Move traffic to a ready deployment. | | `valkor apps access ` | Read or update the access policy. | | `valkor apps access-link ` | Create a short-lived authenticated browser URL. | | `valkor apps delete ` | Delete the App and its runtimes. `--yes`. | `--project`, `--host`, and `--json` work on every subcommand. `valkor apps set` sends only the flags you pass, and needs project write access. `--memory-gb` accepts `--memory` and `--disk-gb` accepts `--disk` as aliases. `--idle-timeout` takes 120-86400 seconds. A machine or budget change applies to the next deployment, not to the running runtime. ### Deployment defaults from `valkor.yaml` An `apps.` block holds local deploy defaults. The server stays the App control plane: the block never carries access, passwords, or member ids. ```yaml apps: docs: path: docs/dist type: static spa: true readiness_path: / idle_timeout_seconds: 300 monthly_budget_usd: 5 resources: cpu: 1 memory_gb: 2 disk_gb: 10 env: PUBLIC_BASE: https://beeuni.ai secrets: API_TOKEN: docs_api_token ``` Select the block with `valkor apps deploy --manifest-app docs`. An explicit flag always wins over the block. ### Machine, idle timeout, and budget | Setting | Default | Bounds | |---|---|---| | `cpu` | `1` | `1` to `32` cores | | `memory_gb` | `2` | `1` to `128` GiB | | `disk_gb` | `10` | `1` to `500` GiB | | `idle_timeout_seconds` | `300` | `120` to `86400` | | `monthly_budget_usd` | `5` | `0` to `100000`, or the operator's `VALKOR_APPS_MAX_MONTHLY_BUDGET_USD` | Apps reject a machine larger than the limits instead of clamping it. An App records its requested spec and bills off that record, so a silent downgrade would charge for compute the provider never gave. An out-of-range value answers `400` with `code: "app_machine_out_of_range"` or `"app_budget_out_of_range"`. Creating an App past the account's App quota answers `402` with `code: "app_quota_exceeded"`. A duplicate slug in the same project answers `409`. ## The stable URL Valkor assigns the hostname when the App is created and never changes it. On Valkor cloud it is `--.apps.beeuni.ai`. Self-hosted deployments serve their own wildcard domain from `VALKOR_APPS_BASE_DOMAINS`. An authorized request to a suspended App resumes its sandbox, waits for readiness, and proxies that same request. You do not have to wake it first. While the App is waiting for its first deployment, queued, validating, building, provisioning, checking, activating, or starting: - A browser navigation gets a branded status page, HTTP `202`, `retry-after: 3`, and a 3-second meta refresh. - A machine client gets `202` and JSON — for a cold start, `{ code: "app_starting" }` with `retry-after: 3`. Terminal and paused states answer differently: | State | HTTP | `code` | |---|---|---| | Deployment failed | `503` | `app_deployment_failed` | | Deployment cancelled | `503` | `app_deployment_cancelled` | | Monthly compute budget reached | `402` | `app_budget_exceeded` | | Account cannot start compute | `402` | `app_account_unfunded` | | Account at its concurrent-App limit | `429` | `app_concurrency_limit` | `valkor apps stop` suspends compute immediately; the next authorized request resumes the App. `valkor apps start` warms it before traffic arrives. > **Cold starts stay invisible** > The stable URL never exposes an `app_stopped` state. A provider edge that > answers `502` during the first request after a resume is served as the ordinary > cold-start page instead. A warm App owns its own HTTP status, including a > deliberate application `502`. ## Access modes An App's access mode is a per-resource visibility setting on top of the role model, not a role. It decides who can open this one App. It grants no permission the role verdict denies. See [Accounts & access](/docs/accounts#per-feature-access-settings). New Apps are private. Choose one mode: | Mode | Who can open the App | |---|---| | `private` | The creator only | | `project` | Every principal who can read the project | | `restricted` | Selected users and groups | | `public` | Anyone, with no authentication | | `password` | Anyone with the App password | Set it at deploy time or afterwards: ```bash valkor apps deploy . --access restricted --members m1,m2 --groups g1 valkor apps access docs --mode password --password 's3cret' valkor apps access-link docs --json ``` Valkor access uses a five-minute exchange URL and an eight-hour, host-only, secure cookie. `access-link` mints that exchange URL without changing the policy — treat it as a secret. Changing an access policy increments its revision, which revokes every existing App cookie. Passwords are Argon2id hashes; the API, the CLI, and the SDK never return a password or its hash. > **Never put an App password in your repo** > `valkor.yaml` holds deployment defaults only. Pass a password with `--password`, > or set it from the access modal. Being able to see an App listed and being able to open it are different verdicts. A project manager sees every App in the project, so a private App stays manageable when its creator leaves. An account owner and an account admin hold manager-equivalent access on every project, so the same applies to them. The App record reports `viewer_can_access` for the second question. ## Versions and rollback Each deployment gets the next version number for its App and is immutable. The deployment record keeps its source kind, hosting provider, build and runtime spec, attempt count, and error code. Every deployment also records who made it: `created_by`, `actor_type` (`human`, `agent`, `service_account`, or `system`), and the originating `source_session_id` when an agent deployed it. Move traffic back to any ready deployment: ```bash valkor apps rollback docs ``` Rollback starts the target deployment's runtime first, then stops the previous one. A target that fails to start leaves the current deployment serving. Each cold start compares the active deployment's runtime version against the current Valkor App runtime. The old deployment keeps serving while Valkor asynchronously builds one immutable replacement with the latest `valkor-appd` and Caddy binaries. A PostgreSQL advisory lock prevents duplicate refreshes. ## The Apps page Once the flag is on, an **Apps** row appears in the project sidebar, under Customize. The page is operational, not a creation surface: it lists the project's Apps with live state, a signed preview of each running App, and the access controls. Deploying is `valkor apps deploy .`. An App with no active deployment reads **Not deployed**, never **Running**. A suspended App's preview issues the request that wakes it. Valkor opens `*.apps.beeuni.ai` and `*.apps.localhost` on their direct origin rather than through a session's web forward proxy. That preserves the host-only access cookie and removes one network hop. --- # Feature flags Turn a Valkor surface on for one project, and read what every flag gates. Canonical page: https://beeuni.ai/docs/feature-flags A feature flag turns one Valkor surface on for one project. Any surface can ship behind a flag — experimental, beta, or fully stable. "Experimental" is a stability badge on a flag, not the name of the system. Flags are per project. Turning a flag on in one project changes nothing in another project, and nothing for other accounts. - [Apps](/docs/feature-flags/apps): Deploy static sites, bundles, Dockerfiles, and OCI images to stable URLs. ## Turn a flag on 1. Open **Settings → Experimental**. The tab lists every flag the platform supports. 2. Read the row: the flag name, its stability badge, one sentence of description, and its origin — `Default on`, `Default off`, or `Overridden for this project`. 3. Use the switch. The change applies to the current project immediately. You need the project's `project.customize.write` permission. The route answers `403` for any other caller. ### From the CLI The same switches are available to scripts and agents through `valkor projects features`: ```bash valkor projects features # every flag: key, state, origin, stability valkor projects features enable apps valkor projects features disable voice valkor projects features reset apps # drop the override; follow the platform default valkor projects features --json # the full catalog as JSON ``` Add `--project ` to act on a project other than the linked/default one. A flag the platform marks unavailable stays off whatever the project override says; the CLI prints that as `n/a` / `unavailable`. > **Flag state is not in your repo** > Per-project flag state lives in the database, on the project row. It is never > read from `valkor.yaml`. A flag you turn on does not travel with a repository > clone or a fork. ## The two gates Each flag has two gates. They answer different questions. | Gate | Question | Effect when false | |---|---|---| | `available` | Does this deployment support the flag at all? | The toggle is hidden and the surface stays dark, whatever the project chose. | | `enabled` | Is the flag on for this project? | The surface stays dark for this project. | `enabled` is the project's explicit choice over the platform default, then AND-gated by `available`. `enabled` therefore always implies `available`. `available` is an operator decision, made by the environment the API runs in. Three flags read it from configuration; every other flag is always available: | Flag | Available when | |---|---| | `agent_tunnel` | `TUNNEL_ENABLED` is on | | `llm_gateway` | `LLM_GATEWAY_ENABLED` is on | | `monitors` | `PLATINUM_API_KEY` is set | ## Stability badges The badge describes the contract, not the switch. A `stable` flag is still an opt-in: `apps` is stable and still off by default. | Badge | What it means | |---|---| | Experimental | The surface and its contract can still change. | | Beta | The surface works and the shape is settling. | | Stable | The contract holds. The flag stays an opt-in. | ## How a flag is enforced Every flag declares one enforcement mode. | Mode | What the server does when the flag is off | |---|---| | `routes` | The HTTP surface rejects the request with `403`. | | `behavioral` | The behavior does not occur — no connector materializes, no env injects, no agent registers. | | `ui-only` | The server deliberately does not enforce. The flag hides client surface only. | A `routes` rejection is identical everywhere: ```json { "error": "Apps is not enabled for this project. Enable it in Settings → Feature flags.", "code": "feature_disabled", "feature": "apps" } ``` The `error` string names the flag list, not a specific tab. The list is the **Experimental** tab of Settings. Branch on `code`, never on the message text. The SDK exports `isFeatureDisabledError(error)` and `featureDisabledKey(error)` for exactly this. ## Every flag Registry order — the same order **Settings → Experimental** shows. | Key | Name | Stability | Default | Enforcement | |---|---|---|---|---| | `marketplace` | Marketplace | Beta | On | `routes` | | `agent_tunnel` | Agent Computer Tunnel | Experimental | Off | `ui-only` | | `connectors_api_discover` | Connectors API Discover | Experimental | Off | `routes` | | `agentmail_email` | AgentMail Email | Experimental | Off | `routes` | | `teams` | Microsoft Teams | Experimental | Off | `routes` | | `voice` | Voice | Experimental | Off | `behavioral` | | `llm_gateway` | LLM Gateway | Experimental | On (operator can default off) | `behavioral` | | `review_center` | Review Center | Experimental | Off | `routes` | | `meta_agent` | Meta Agent | Experimental | Off | `behavioral` | | `apps` | Apps | Stable | Off | `routes` | | `monitors` | Monitors | Experimental | Off | `routes` | | `warm_sessions` | Warm Sessions | Beta | On | `routes` | `llm_gateway` reads its per-project default from `LLM_GATEWAY_DEFAULT_ENABLED`, which defaults to on. Turning the flag off per project is a first-class path: the project runs native OpenCode model management (provider keys injected into the sandbox, native `provider/model` refs). An explicit project choice always wins. ### What each flag gates - **`marketplace`** — browse and install skills from community and vendor registries. - **`agent_tunnel`** — let agents reach a local machine over a permissioned reverse tunnel. See [Computer Tunnel](/docs/connect/computers). - **`connectors_api_discover`** — browse direct API, MCP, GraphQL, CLI, and Postman surfaces beside Pipedream OAuth apps. See [Connectors](/docs/connect/connectors). - **`agentmail_email`** — assign AgentMail inbox connections so inbound email starts and continues sessions. - **`teams`** — connect a Microsoft Teams bot so chats and channels start and continue sessions. See [Slack & channels](/docs/connect/slack). - **`voice`** — give the agent a live voice call it can start and hold. See [Slack & channels](/docs/connect/slack). - **`llm_gateway`** — route the project through the managed Valkor LLM gateway. See [Models](/docs/project/models). - **`review_center`** — one inbox for change requests, approvals, and agent output. - **`meta_agent`** — add a platform-owned coordinator agent that spawns and manages specialized sessions. - **`apps`** — deploy static sites, bundles, Dockerfiles, and OCI images to stable serverless URLs. See [Apps](/docs/feature-flags/apps). - **`monitors`** — run 24/7 watchers from your repo that fire trigger events into sessions. See [Triggers](/docs/connect/triggers). - **`warm_sessions`** — keep one sandbox booted while a project is open, so a new session starts without a cold boot. ## Side effects of a toggle Some flags converge platform state after the write commits. | Flag | Effect after the toggle | |---|---| | `voice`, `teams`, `agentmail_email` | Valkor re-runs channel-connector materialization, so the connector appears or disappears with the flag. | | `agent_tunnel` | Valkor re-syncs the account's computer connectors. | | `llm_gateway` | Valkor propagates the new provider mode to active sandboxes. | Effects are convergence work, not part of the toggle's success. The API response does not wait for them. Each effect is retried once, and the reconcilers behind it are idempotent and re-run on their periodic sweeps. ## Read and set a flag from code Read the effective per-project state through the project detail, or through the React hook: ```tsx import { useFeatureFlag } from '@valkor/sdk/react'; function AppsNavItem({ projectId }: { projectId: string }) { const apps = useFeatureFlag(projectId, 'apps'); if (!apps.enabled) return null; return Apps; } ``` `enabled` is `true` only when the server said exactly `true`. A missing project id, an in-flight query, and an error all resolve to `false`. Gate fail-closed. Set the project override with the client: ```ts const p = valkor.project(projectId); await p.updateFeatureFlag('apps', true); // turn it on for this project await p.updateFeatureFlag('apps', null); // clear the override, inherit the default ``` `updateFeatureFlag` calls `PATCH /v1/projects/:id/features`. `feature` is one of `FEATURE_FLAG_KEYS`, exported from `@valkor/sdk` and typed as `FeatureFlagKey`. See [SDK reference](/docs/sdk/reference). Handle a disabled feature by code, not by message: ```ts import { featureDisabledKey, isFeatureDisabledError } from '@valkor/sdk'; try { await valkor.project(projectId).apps.list(); } catch (error) { if (isFeatureDisabledError(error)) { console.log(`${featureDisabledKey(error)} is off for this project`); } } ``` --- # Self-hosting architecture How the self-hosted Docker Compose stack fits together, on the box and off it. Canonical page: https://beeuni.ai/docs/host/architecture This page shows how the pieces of a self-hosted Valkor instance fit together: one Docker Compose stack, plus the compute that stays outside it. For install steps, see the [self-hosting guide](/docs/host). Self-hosted Valkor is one generic Docker Compose system, not a family of deployment targets. `valkor self-host init` renders a `docker-compose.yml` and `.env` file (plus a `Caddyfile` and `updater.sh` when you set a domain) into `~/.config/valkor/self-host//`. `valkor self-host start` runs `docker compose up`. The same artifact runs on a laptop, a VPS, or any cloud VM. A domain is only the `VALKOR_DOMAIN` environment variable, not a different setup. Production self-hosting needs a persistent domain pointed at the box. A domain gives Caddy a stable name for ACME TLS, and gives agent sandboxes a stable URL to call back to. Without a domain or a tunnel, sessions cannot run, because the sandbox has no way to reach the API. For evaluation without a domain, use `valkor self-host init --tunnel cloudflare` instead. ## One box, one Compose stack ```mermaid flowchart TB subgraph internet["Internet"] user["Browser / API client"] end subgraph box["One host: laptop, VPS, or cloud VM"] subgraph compose["docker compose (one project per instance)"] caddy["Caddy\n(only when VALKOR_DOMAIN is set)\nACME TLS on 80/443"] frontend["frontend"] api["valkor-api"] gateway["llm-gateway"] updater["valkor-updater\n(pull -> migrate -> roll,\nonce daily at a fixed time)"] subgraph supabase["Supabase Docker distribution"] kong["supabase-kong"] auth["supabase-auth"] rest["supabase-rest"] storage["supabase-storage"] db[("supabase-db (Postgres)")] end end vol_db[("bind mount:\nvolumes/db/data")] vol_storage[("bind mount:\nvolumes/storage")] end subgraph external["Outside the box"] daytona["Daytona\n(agent sandboxes, default)"] registry["docker.io/valkor/*\n(image registry)"] end user -->|"80/443, TLS"| caddy user -.->|"no domain: local ports"| frontend caddy -->|"/v1/llm*"| gateway caddy -->|"else"| api caddy -->|"Supabase data-plane paths"| kong caddy -->|"else"| frontend frontend --> api api --> gateway api --> kong kong --> auth kong --> rest kong --> storage auth --> db rest --> db storage --> db db --> vol_db storage --> vol_storage updater -->|"docker compose pull"| registry updater -->|"migrate, then roll"| compose api -->|"provision and run sessions"| daytona ``` ## What runs on the box - **Caddy** — reverse proxy and ACME TLS. Valkor renders this service only when you set `VALKOR_DOMAIN`; a domain-less instance never opens ports 80/443. Caddy routes `api.` to the gateway (for `/v1/llm*`) or the API, and `` to Kong (for Supabase data-plane paths) or the frontend. - **`valkor-api`, `llm-gateway`, `frontend`** — the three application images. They track the same channel, or a version you pin explicitly. - **The Supabase Docker distribution** — Kong, GoTrue auth, PostgREST, Storage, Realtime, Studio, imgproxy, meta, functions, and the Supavisor connection pooler. Valkor vendors this from upstream Supabase and pins every image by digest. - **`valkor-updater`** — a small container with the Docker socket mounted. It checks for a new image once a day, at a fixed local clock time (`VALKOR_UPDATE_TIME`, default `02:00`, in `VALKOR_UPDATE_TZ`, default `America/New_York`). If an image changed, it runs the `valkor-migrate` job, then starts new containers before it stops the old ones. This start-first swap is zero-downtime only when the box runs two replicas (domain mode). A single-replica box (tunnel or local mode) uses a different, brief-downtime swap instead. - **Data** — two bind mounts under the instance directory: `volumes/db/data` for Postgres and `volumes/storage` for Supabase Storage. The `.env` file holds every secret. ## What runs outside the box - **Agent sandboxes** — by default, Daytona. You can configure Platinum or E2B instead. `valkor-api` reaches the sandbox provider over egress; sandbox compute never runs on the self-host box. - **The image registry** — `docker.io/valkor/*`. The updater and `valkor self-host start` pull from it. It needs no credentials. > **Warn** > `valkor self-host uninstall` runs `docker compose down --volumes > --remove-orphans` and deletes the instance directory. This removes your > database and storage bind mounts. Back them up first. ## Channels and updates Every instance tracks one of two moving tags, or a version you pin explicitly: | Channel | Meaning | |---|---| | `stable` (default) | Curated. A human promotes a proven version to `stable` on a separate schedule from prod releases. | | `latest` | Every prod release retags `latest` automatically. | | `--tag ` | Pins an exact version. Overrides the channel. | `valkor-updater` and `valkor self-host update` (alias `reconcile`) resolve the same way: an explicit pin wins, otherwise the configured channel. Self-hosted instances only consume images this pipeline has already built; they never build or sign anything themselves. See the [self-hosting guide](/docs/host) for install steps and the [CLI reference](/docs/cli) for the full `valkor self-host` command surface. --- # Self-hosting Run your own Valkor instance with Docker Compose, on a VPS or for evaluation. Canonical page: https://beeuni.ai/docs/host Valkor runs as one Docker Compose stack: the frontend, the API, the LLM gateway, and the Supabase distribution. This page shows the three ways to install it, how updates work, and how to back up your data. Agent sessions run on a separate sandbox provider, not on this stack. The default is [Daytona](https://www.daytona.io/); Platinum and E2B are also supported. ## One-shot bootstrap On a bare Linux box, one command installs Docker, installs the `valkor` CLI, and starts the stack: ```sh curl -fsSL https://raw.githubusercontent.com/valkor-ai/valkor/main/scripts/valkor-selfhost-up.sh \ | bash -s -- --domain valkorai.example.com --email ops@example.com ``` This script runs on Linux only. On another OS, install the CLI directly and use the manual path below. ## Manual path ### Install the CLI ```sh curl -fsSL https://beeuni.ai/install | bash ``` ### Point DNS, then initialize Create an A/AAAA record for your domain and for `api.`, both pointing at the box's IP. Open ports 80 and 443 — the bundled Caddy proxy uses them to issue a TLS certificate. Then run: ```sh valkor self-host init --domain valkorai.example.com ``` ### Start the stack ```sh valkor self-host start ``` Check `valkor self-host status`, `logs`, and `doctor` while the stack starts. ## Evaluation mode To try Valkor with no domain, use a Cloudflare tunnel instead of a domain: ```sh valkor self-host init --tunnel cloudflare valkor self-host start ``` The tunnel URL changes on every restart. Use this mode for evaluation, not production. After the stack starts, set your sandbox provider key: ```sh valkor self-host configure ``` `configure` is an interactive prompt for the sandbox provider key, and optionally a managed-git token. Sign up in the dashboard, then connect your own LLM key in the model picker. Self-hosted instances use your own key by default. > **Info** > By default, only the platform admin can create new organization accounts. The > platform admin is the super-admin flag on a membership, not a role. > Any signed-in user can still join by invite or SSO. Opt out with > `valkor self-host init --no-restrict-account-creation`, or re-enable the > admin-only default with `--restrict-account-creation`. Each API container has a 640 MiB memory limit by default. Keep this default on an 8 GiB host. Use a 1 GiB limit on a 16 GiB host when API traffic reaches the default limit: ```sh valkor self-host env set VALKOR_API_MEMORY_LIMIT=1024m ``` Confirm the applied limit with `docker stats --no-stream`. ## Updates Every instance updates itself automatically. Pin an exact version instead: ```sh valkor self-host update --tag 0.9.84 ``` Turn the updater off with `--auto-update off`. See [Self-hosting architecture](/docs/host/architecture) for the update schedule, the zero-downtime swap, and the channels. ## Backups Valkor has no separate backup system. Each instance stores its data as two directories under `~/.config/valkor/self-host//`: `volumes/db/data` (the Postgres database) and `volumes/storage` (file storage). The instance's `.env` file holds every secret and signing key it uses. Back up all three before you run a destructive command. > **Warn** > `valkor self-host uninstall` stops the stack, deletes its containers and > volumes, and deletes the instance directory. This cannot be undone. ## Learn more - [Self-hosting architecture](/docs/host/architecture) — how the stack fits together. - [CLI reference](/docs/cli) — every `valkor self-host` subcommand and flag. --- # Valkor Valkor is the AI command center for your company. Canonical page: https://beeuni.ai/docs Valkor is the Autonomous Company Operating System — a cloud computer where a workforce of AI agents runs your company, and everything is code you own. Each agent works inside your own git repo. A session runs an agent in an isolated sandbox, on its own branch. When the work is ready, the agent opens a change request. You review it and merge it to the default branch. ``` project (git repo + valkor.yaml) └─ session ──> isolated sandbox on branch "" └─ agent commits + pushes └─ change request ──> merge ──> default branch ``` New projects use a `valkor_version: 2` manifest and run OpenCode. Existing v1 projects keep working with no forced upgrade. - [Quickstart](/docs/quickstart): Create a project, start a session, and merge your first change request. - [Your project](/docs/project): Set up the manifest, agents, models, and secrets. - [Running work](/docs/work): Sessions, sandboxes, and change requests. - [Connect & automate](/docs/connect): Connectors, Slack, computers, and triggers. - [Feature flags](/docs/feature-flags): Per-project opt-ins, including Apps. - [CLI](/docs/cli): Develop and run Valkor from your terminal. - [SDK](/docs/sdk): Build on Valkor from TypeScript with the client SDK. - [Self-hosting](/docs/host): Install and operate Valkor on your own infrastructure. --- # Agents An agent is a markdown file that OpenCode runs; the manifest only governs its access. Canonical page: https://beeuni.ai/docs/project/agents An agent is a markdown file that defines how OpenCode acts in a [session](/docs/work/sessions). This page covers agent files, skills, and how a session picks an agent. ## OpenCode runs every session OpenCode, the open-source coding-agent runtime, runs every session. The `valkor-agent` daemon starts it as `opencode serve`, with its config directory pointed at the project's `.valkor/opencode/` folder. ## An agent is two files Each agent has two parts: - **Behavior** — a markdown file at `.valkor/opencode/agents/.md`. Its frontmatter sets the model, mode, and tools. Its body is the system prompt. - **Governance** — an entry in `valkor.yaml`'s `agents` map, keyed by the same name. It sets what the agent may access on Valkor: connectors, secrets, skills, and CLI actions. The manifest never sets a prompt, mode, or tool. The `.md` file never sets platform access. See [the manifest reference](/docs/project/manifest) for every governance field. ## Governance is deny-by-default An agent with no `connectors`, `secrets`, `skills`, or `valkor_cli` key gets none of that access. This grant is the second of the two bindings: the agent also holds roles as a `service_account` principal, and a session can only do what both allow — see [One vocabulary, two bindings](/docs/accounts#one-vocabulary-two-bindings). Set a field to `all` to grant full access, or list specific names. `default_agent` in `valkor.yaml` names the agent a session starts when you request no agent. Legacy `valkor.toml` (v1) projects list agents in an array, not a map, and grant full access by default. Set specific grants to restrict access. See [the manifest reference](/docs/project/manifest). ## Skills give an agent know-how A skill is a markdown file at `.valkor/opencode/skills//SKILL.md`. OpenCode loads a skill on demand when the agent calls it — Valkor does not inject skills into every prompt. An agent's `skills` grant in `valkor.yaml` controls which skills it may load. ## How a session picks its agent Creating a session accepts an `agent_name`. Valkor starts that agent for the session. If you omit `agent_name`, Valkor uses the project's `default_agent`. Set the default agent from the dashboard, the API, or the SDK. Each writes `default_agent` to `valkor.yaml` and commits the change to the project's default branch. An agent never exceeds the access of the person or token that started the session. New or changed agents and skills reach future sessions only after a [change request](/docs/work/change-requests) merges them into the default branch. Connect external tools before an agent can use them; see [Connectors](/docs/connect/connectors). --- # Your project A project is a git repository that holds your agent's config and state. Canonical page: https://beeuni.ai/docs/project A project is one git repository. It holds a manifest, agent config, and the state your agent produces. No separate database exists to keep in sync. Valkor backs a project two ways: - **Valkor-managed repo** — Valkor creates and hosts a private repo. Default. - **Imported GitHub repo** — connect an existing repo. Valkor operates on it through the GitHub API. Either way, the project has a `default_branch`. Every [session](/docs/work/sessions) branches from it. Every [change request](/docs/work/change-requests) merges into it. Each branch of a GitHub repo can become its own project. `main` and `dev` can then run as separate projects, each with its own sessions and change requests. ## A project is not your codebase A project holds your agent's instructions, its [connectors](/docs/connect/connectors), its [triggers](/docs/connect/triggers), and its memory. Keep it small — Valkor clones it into every session. Your code lives elsewhere. When a task needs a codebase, the agent clones that repository, does the work, and opens a change request back to it. Do not turn an existing codebase into a project by adding a manifest to it. Create a dedicated project instead, and point it at the repositories the agent works on. ## The manifest Every project has a manifest at its repo root, `valkor.yaml` by default. The platform reads these top-level keys: | Key | Configures | |---|---| | `default_agent` | which agent runs by default | | `agents` | per-agent grants: connectors, secrets, skills, Valkor CLI permissions | | `sandbox` | the sandbox image and hardware | | `triggers` | scheduled and webhook automation | | `connectors` | which external tools the project can reach | | `env` | env variable names the project expects (values come from secrets) | Unknown keys are ignored. Older projects may still run `valkor.toml` (`valkor_version: 1`). Valkor reads both formats. See [legacy TOML](/docs/project/legacy-toml) for the migration path. ## In this section - [Manifest](/docs/project/manifest): The full valkor.yaml reference. - [Agents](/docs/project/agents): Markdown personas with scoped tools. - [Models](/docs/project/models): Which model a session uses, and who pays. - [Secrets](/docs/project/secrets): Encrypted values injected into a sandbox. - [Legacy TOML](/docs/project/legacy-toml): Migrating from valkor.toml. --- # Legacy valkor.toml Support status and the migration path from v1 valkor.toml to v2 valkor.yaml. Canonical page: https://beeuni.ai/docs/project/legacy-toml This page covers the v1 manifest (`valkor.toml`, `valkor_version: 1`). For the current manifest, see [Manifest reference](/docs/project/manifest). ## Support status Valkor still supports v1 manifests. The platform resolves a project's manifest in this order: `valkor.yaml`, then `valkor.yml`, then `valkor.toml`. The current starter creates a `valkor_version: 2` `valkor.yaml`. A v1 project keeps working with no forced upgrade. v1 accepts TOML or YAML syntax. Version 2 accepts YAML only. A v2 manifest written in TOML fails validation. ## Migrate to v2 Create `valkor.yaml` at the repo root. Set `valkor_version: 2`. Convert `[[agents]]` to an `agents:` map, keyed by agent name. Rename each agent's `env` field to `secrets`. List every secret the agent needs — v2 does not grant secrets by default. Add `default_agent`, naming the agent that runs by default. Delete `[[channels]]`. Reconnect each channel from the dashboard. If you use `[sandbox]` or `[[sandboxes]]`, move each image definition under `sandbox.templates`. Run `valkor validate` against `valkor.yaml`. Fix any error it reports. Delete `valkor.toml`. Agent behavior — system prompt, `model`, `mode`, `temperature`, and more — never moves. It already lives in each agent's `.md` frontmatter under `.valkor/opencode/agents/`, in both v1 and v2. See [Agents](/docs/project/agents). Check the result with `valkor validate --file valkor.yaml`. Fetch the full v2 schema with `valkor schema --version 2`. See [CLI reference](/docs/cli) for both commands. ## Key differences | v1 (`valkor_version: 1`) | v2 (`valkor_version: 2`) | Change | |---|---|---| | `[[agents]]` (array) | `agents:` (map keyed by name) | Convert each array item to a map entry. | | `[[agents]].env` | `agents..secrets` | Renamed. Default flips from `all` to `none` — see the callout below. | | `[[agents]].model`, `[[agents]].file` | removed | Dead in v1 (parsed, never applied). Agent behavior always comes from the agent's `.md` frontmatter. | | no `default_agent` | `default_agent` (required) | Must name a declared, enabled agent. | | `[[channels]]` | removed | Channel routing is dashboard-managed. A connected channel appears as a `connectors:` entry with `provider: channel`. | | `[sandbox]` (singular, image keys directly on it) | `sandbox.templates` (list) | Move each image into a template entry. | | `[[sandboxes]]` | `sandbox.templates` | Renamed. | > **Secrets default to none in v2** > In v1, an agent with no `env` field gets every project secret by default. In v2, an agent with no `secrets` field gets none. When you migrate, list every secret each agent needs — an agent that silently loses a secret can fail mid-task. A few `valkor_cli` actions are legacy-tolerated in v1 (`project.session.exec`, `project.gateway.routing.edit`, `project.schedule.read`, `project.schedule.write`, `project.webhook.read`, `project.webhook.write`, `channel.read`, `channel.connect`, `channel.send`, `channel.disconnect`) — the platform warns but allows them. The same actions are a hard error in v2. Remove them from any agent's `valkor_cli` grant before you migrate. --- # Manifest reference Every valkor.yaml v2 key, with defaults, limits, and validation rules. Canonical page: https://beeuni.ai/docs/project/manifest The manifest is the file the platform treats as authoritative for a project. This page lists every `valkor.yaml` (v2) key, its type, its default, and how the platform validates it. For the plain-language version, see [Your project](/docs/project). ## Two configuration surfaces A Valkor project has two configuration surfaces with strict, non-overlapping ownership. - **Valkor config** — `valkor.yaml` at the repo root, plus `.valkor/Dockerfile`. The platform reads this: the trigger sweep, the sandbox builder, session token minting, and the dashboard. - **OpenCode config** — everything under `.valkor/opencode/` (`opencode.jsonc`, `agents/`, `skills/`, `commands/`, `tools/`, `plugins/`). The OpenCode runtime reads this, in the sandbox and locally. The join between the two halves is the agent name. A manifest key `agents.` must match the filename `.valkor/opencode/agents/.md`. Every behavioral field lives only in that `.md` file's frontmatter and body, never in `valkor.yaml`: - system prompt - `model`, `mode` - `temperature`, `top_p` - `steps`, `permission` The manifest's `agents:` block sets governance only: which agents may launch, and what each one may touch. The validator enforces this. It rejects a v2 agent block that contains any behavioral field, with an error that points at the agent's own `.md` file. ## Location and versions Any repo with a valid manifest at its root is a Valkor project. `valkor.yaml` (v2, YAML only) is the current format for new projects, created by the web "Create project" flow and by `valkor init`. `valkor.toml` (v1) still works for existing projects, but the platform accepts no new v2 features on it. See [Legacy TOML](/docs/project/legacy-toml) for the v1 schema and the v1-to-v2 migration steps. `valkor_version: 2` requires YAML — a `.toml` file that declares `valkor_version: 2` fails validation. A manifest that declares a version higher than `2` is rejected outright, so the platform never silently misreads a future field. Unknown top-level keys are ignored, so you can park your own metadata in the file. `validateManifest()` is the single gate behind `valkor ship`, the change request merge check, and `valkor validate`. The same rules apply everywhere, so anything that merges into `main` is structurally sound. The schema is public and generated from the same package: - [`valkor.v2.schema.json`](/schema/valkor.v2.schema.json) - [`valkor.v1.schema.json`](/schema/valkor.v1.schema.json) - [`valkor.schema.json`](/schema/valkor.schema.json) (dispatches on `valkor_version`) Point a `valkor.yaml` at the v2 schema for editor validation, or fetch it from the CLI: ```yaml # yaml-language-server: $schema=https://valkor.com/schema/valkor.v2.schema.json valkor_version: 2 ``` ```bash valkor schema --version 2 ``` ## Full example ```yaml valkor_version: 2 default_agent: valkor project: name: my-project description: What this project is. env: required: [DATABASE_URL] optional: [STRIPE_API_KEY, WEBHOOK_SLACK_SECRET] sandbox: templates: - slug: ml name: ML Development dockerfile: .valkor/Dockerfile.ml cpu: 4 memory: 16 opencode: config_dir: .valkor/opencode agents: valkor: connectors: all secrets: all valkor_cli: all skills: all release-bot: sandbox: ml connectors: [github] valkor_cli: [project.cr.open] secrets: [GITHUB_AGENT_TOKEN] triggers: - slug: daily-digest type: cron agent: valkor cron: '0 0 9 * * 1-5' timezone: America/Los_Angeles prompt: | Summarize yesterday's commits. Open a CR against main. ``` ## Top-level keys | Key | Required | Notes | | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------ | | `valkor_version` | yes | must be `2` | | `default_agent` | yes | must name a declared, enabled agent | | `runtime` | no | only legal value is `"opencode"` (the default) | | `project.name` / `project.description` | no | display metadata; the platform does not read `project.name` for the project's display name | | `env.required` / `env.optional` | no | env var names; `required` is advisory only, never enforced at session start | | `sandbox` / `sandbox.templates` / `sandbox.default` | no | sandbox image(s) and hardware | | `opencode.config_dir` | no | default `.valkor/opencode` | | `triggers` | no | list of cron, webhook, and monitor triggers | | `connectors` | no | list of connector definitions | | `policy.default_mode` | no | `allow_all` (default) or `risk`; project-wide connector approval mode | | `agents` | yes | name-to-block governance map; must not be empty | ## `project:` Optional, human-facing metadata: `name` and `description`. The platform does not read this table for anything — a project's display name comes from its own database record. Treat it as documentation for people reading the repo. ## `env:` Declares the env var names your sessions need. Values live in the dashboard's Environment variables page, never inline in the manifest. The platform decrypts and injects them as plain env vars at session start. ```yaml env: required: [DATABASE_URL] optional: [STRIPE_API_KEY] ``` | Field | Type | Notes | | ---------- | ---------- | -------------------------------------------------------------------------------------------------------- | | `required` | `string[]` | Advisory. The dashboard prompts the user for these, but session start does not block on a missing value. | | `optional` | `string[]` | Available to sessions if set. Absence is fine. | Env var names match `^[A-Z_][A-Z0-9_]*$`. The Secrets Manager caps a name at 64 characters — a longer name parses here but can never get a value. Names starting with `VALKOR_` can never get a value either. Keep names at 64 characters or fewer, and avoid the `VALKOR_` prefix. Full contract: [Secrets](/docs/project/secrets). ## `sandbox:` and `sandbox.templates` A list of named, bootable sandbox images. Optional — with no entries, every session boots the platform's default image. Each template needs exactly one of `dockerfile` (repo-relative) or `image` (a public Docker reference, tag- or digest-pinned). ```yaml sandbox: templates: - slug: ml name: ML Development dockerfile: .valkor/Dockerfile.ml cpu: 4 memory: 16 disk: 50 ``` | Field | Type | Default | Notes | | ------------ | ------ | ------------------- | --------------------------------------------------------------------------------- | | `slug` | string | — | Required. Unique per project. `default` is reserved. | | `name` | string | slug | Display label in the dashboard picker. | | `dockerfile` | string | — | Repo-relative path. Mutually exclusive with `image`. | | `image` | string | — | Public Docker image, tag- or digest-pinned. Mutually exclusive with `dockerfile`. | | `entrypoint` | string | runtime layer's own | Overrides the container entrypoint. | | `cpu` | int | provider default | vCPU cores. Bound: 1–32. | | `memory` | int | provider default | RAM in GiB. Bound: 1–128. | | `disk` | int | provider default | Disk in GiB. Bound: 1–500. | Each value must be a positive integer. A value below the minimum fails validation with an error. A value above the maximum passes with a warning, then clamps at runtime. GPUs are not supported — declaring `gpu` produces a warning, not an error. See [Runtime](/docs/work/runtime) for what the runtime layer injects on top of your image. ### `sandbox.default` Set `default` on `sandbox` to make one template the project-wide default. Every session, trigger, and channel then boots it without naming a slug. ```yaml sandbox: default: dev templates: - slug: dev dockerfile: .valkor/Dockerfile ``` `default` must name a template declared in this manifest, or the reserved value `"default"` for the platform image. An agent can select its environment with `agents..sandbox`. The value must name a manifest or dashboard template, or `"default"`. ```yaml agents: researcher: sandbox: ml ``` Session template resolution uses this order: 1. Explicit session `sandbox_slug`. 2. Selected agent `sandbox`. 3. Project `sandbox.default`. 4. Platform `"default"`. Cron triggers, webhook triggers, schedules, and channels use the selected agent's template. A spec change (`cpu`, `memory`, `disk`, or the image itself) rebuilds the project's snapshot. The new size applies on the next session, not a running one. ## `opencode:` Where the OpenCode config directory lives. Optional, with a default. ```yaml opencode: config_dir: .valkor/opencode ``` | Field | Type | Default | Notes | | ------------ | ------ | ------------------ | ------------------------ | | `config_dir` | string | `.valkor/opencode` | Repo-relative directory. | That directory holds agents, skills, commands, tools, plugins, and `opencode.jsonc`. `opencode.jsonc` stays the OpenCode-native registry for plugins, MCP servers, providers, and permissions — do not duplicate those settings in the manifest. See [Agents](/docs/project/agents). ## `triggers:` A list of cron, webhook, and monitor definitions. Each entry fires a session that runs `prompt` as its first message. ```yaml triggers: - slug: daily-digest type: cron cron: '0 0 9 * * 1-5' prompt: Summarize yesterday's commits. ``` | Field | Required | Default | Notes | | -------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------- | | `slug` | yes | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique among triggers. | | `type` | yes | — | `cron`, `webhook`, or `monitor` (experimental). | | `prompt` | yes | — | Non-empty. Supports templating. | | `name` | no | slug | Human label. | | `agent` | no | `default_agent` | Must name a key in `agents:`. Omit it to use `default_agent`; do not write the literal `default`. | | `enabled` | no | `true` | `false` skips the entry. | | `model` | no | resolves at fire time | Wire form `provider/model`. Pins the fired session to that model. See [Models](/docs/project/models). | | `session_mode` | no | `fresh` | `fresh`, `reuse`, `pinned`, or `keyed`. | | `session_id` | required for `pinned` | — | Exact session to re-prompt. | Cron triggers need exactly one of `cron` (a 6-field expression) or `run_at` (a one-off ISO-8601 timestamp), plus `timezone` as an IANA name. Webhook triggers need `secret_env`, the name of a secret that uses `broker` delivery with the `connector` consumer. Monitor triggers need `run` (a repo-relative command) and `mode` (`poll` or `stream`), and reject every cron and webhook field. Full field reference, credential setup, payload templating, endpoints, and session strategy: see [Triggers](/docs/connect/triggers). ## `connectors:` A list of external tools an agent can call. The definition lives in git; credentials live in the platform, never in the manifest. See [Connectors](/docs/connect/connectors) for the conceptual model. ```yaml connectors: - slug: gmail-read name: Gmail read only provider: pipedream app: gmail authorization_strategy: user policies: - match: search_email action: always_run ``` | Field | Required | Notes | | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `slug` | yes | `[a-z0-9][a-z0-9_-]{0,127}`, unique among connectors. | | `provider` | yes | `pipedream`, `mcp`, `openapi`, `postman`, `graphql`, `http`, or `channel`. | | `name` | no | Display name. Defaults to slug. | | `authorization_strategy` | no | `project` or `user`. Defaults to `project` when omitted. A project strategy uses active project connections. A user strategy uses only the acting member's connection. | | `enabled` | no | Defaults to `true`. | | `credential` | no | Only `shared` is supported. The retired `per_user` mode is a hard error. | | `sensitive` | no | Defaults to `false`. `true` makes `require_approval` the unmatched-action default, including reads. Explicit project and connector rules still resolve first. | Provider-specific fields: | Provider | Required field | Notes | | ----------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `pipedream` | `app` | `account` optional, defaults to slug. | | `mcp` | `url` | `transport`: `http` (default) or `sse`. | | `openapi` | `spec` | A URL or repo-relative path. | | `postman` | `spec` | A Collection JSON URL or path, a `.postman/api` manifest, or a Postman workspace URL. | | `graphql` | `endpoint` | `spec` optional (SDL). | | `http` | `base_url` | `spec` optional. | | `channel` | `platform` | `slack`, `teams`, `email`, or `voice`. | | `computer` | — | API-managed. You cannot declare it by hand. A profile selects one or more paired machines. See [Computer Tunnel](/docs/connect/computers). | `channel` connectors rarely need a hand-written entry — connecting a channel from the dashboard creates one for you. See [Slack & channels](/docs/connect/slack). The slugs `valkor_slack`, `valkor_teams`, `valkor_email`, and `computer` are platform-owned; using one with a different provider is a validation error. Computer Tunnel profiles are created through the connector API because tunnel ids are account control-plane identities and must not enter the manifest. `connectors.auth`: optional, for providers other than `pipedream`. `type` is `bearer`, `basic`, `custom`, `api_key`, `oauth1`, `hmac`, `aws_sigv4`, `mtls`, or `none` (default). `oauth1` is restricted to `openapi`, `postman`, and `http` providers. `in` is `header` (default), `query`, or `cookie`. `name` is required when `type` is `custom` or `api_key`. `connectors.policies`: a list of `{match, action}` pairs. `match` is a glob over tool names. `action` is `always_run`, `require_approval`, or `block`. Policies belong to the connector. Every connection under that connector uses the same policies. `policy.default_mode`: a top-level key, separate from `connectors:`, that sets the project-wide connector approval mode. It takes `allow_all` (default; every unmatched tool runs) or `risk` (require approval for write and destructive unmatched calls). Set it with `valkor connectors policy set --default ` — see [CLI](/docs/cli). ## Channels v2 removes `channels:` from the schema. Channel-to-agent routing (Slack, Microsoft Teams, email, and voice today) is live operational state, not declarative config. You set it from the dashboard's Channels page or from chat commands, the same boundary that keeps credentials out of git. Connecting a channel still creates a `connectors` entry with `provider: channel` for the agent to call. See [Slack & channels](/docs/connect/slack). The v1 `[[channels]]` table is covered in [Legacy TOML](/docs/project/legacy-toml). ## `agents:` A name-to-block map, keyed by agent name. This map is governance only — it grants what an agent may touch, never what it says or does. This block is the **second of the two bindings**: a principal gets roles from Valkor, and an agent additionally carries the Valkor CLI scopes declared here. A session can only do what both allow. See [One vocabulary, two bindings](/docs/accounts#one-vocabulary-two-bindings). ```yaml default_agent: valkor agents: valkor: connectors: all secrets: all valkor_cli: all skills: all release-bot: connectors: [github] connectors_required: [github] valkor_cli: [project.cr.open] secrets: [GITHUB_AGENT_TOKEN] ``` | Field | Default | Notes | | --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | _(map key)_ | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique per project. Matches the `.md` filename it governs. | | `enabled` | `true` | `false` treats the agent as undeclared — the platform will not launch it. | | `connectors` | `none` | Which connector slugs this agent may call. `all` means every connector. | | `connectors_required` | `none` | Connector slugs that must resolve an active, strategy-compatible authorization before sandbox startup. Must be a subset of `connectors`. A missing authorization returns `CONNECTOR_CONNECTION_REQUIRED`. | | `secrets` | `none` | Which project secrets this agent receives as sandbox env vars. Renamed from v1's `env`. | | `valkor_cli` | `none` | Which Valkor CLI and API permissions this agent may exercise. `all` means everything the launching user's roles allow — an agent can never exceed its launcher. | | `skills` | `none` | Which skills this agent may load. | | `workspace` | — | Git-boundary mode: `runtime`, `read`, or `branch`. Validated against the enum; any other value is rejected. | v2 is deny-by-default: an omitted `connectors`, `secrets`, `valkor_cli`, or `skills` on a declared agent resolves to `none`. Grant every permission an agent needs explicitly, as the starter's `valkor` agent does above. A project migrating from v1 must re-grant everything by hand — v1 defaults `env` (secrets) to `all` when omitted, the opposite of v2. `connectors_personal` remains a deprecated input alias for `connectors_required`. New manifests must use `connectors_required`. The connector's `authorization_strategy` decides whether the authorization is project-owned or member-owned. `valkor_cli` grants come from a fixed set of project-scoped permissions (`project.read`, `project.cr.open`, `project.cr.merge`, `project.trigger.*`, `project.secret.*`, `project.connector.*`, and more). Account-scoped permissions — `member.*`, `billing.*`, `project.create` — can never be granted to an agent. Run `valkor validate --scopes` from inside a session to print the live, grantable list. ## When config changes take effect - A manifest or `.valkor/opencode/` edit applies only after a change request merges to `main`. Sessions and the trigger sweep read the default branch, not session branches. - A trigger's cron or webhook change is picked up by the scheduler within seconds of the merge. - A sandbox image or hardware change rebuilds the snapshot. The new spec applies on the next session, not the current one. - A secret value change in the dashboard resolves at sandbox-create time. It applies on the next session, not a running one. ## Round-trip rules Dashboard edits are a read-modify-write on the same file. To keep diffs clean across UI and in-session edits: 1. Keep `valkor_version` as the first key. 2. Inside a trigger entry, order fields `slug`, `name`, `type`, `agent`, `enabled`, then type-specific fields, then `prompt` last. 3. If you add a webhook trigger before its secret is set, list the secret name in `env.optional`. Leave the trigger `enabled: false` until the value is in. --- # Models How Valkor picks a model, and how billing works for managed vs. your-own-key models. Canonical page: https://beeuni.ai/docs/project/models Valkor runs each [session](/docs/work/sessions) on a model. This page explains managed models vs. your own provider key (BYOK), how Valkor picks a model automatically, and two billing gotchas to know. This page applies to projects with the LLM Gateway on. LLM Gateway is an experimental [feature flag](/docs/feature-flags), **on by default** where the platform offers it — check or toggle it in Settings → Experimental (operators can default a whole deployment off with `LLM_GATEWAY_DEFAULT_ENABLED=false`). Turning the flag off is a fully supported path. The project then runs **native OpenCode model management**: - Your provider API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, …) are injected into the sandbox as ordinary env vars — add them on the Model settings page or as [secrets](/docs/project/secrets). OpenCode connects each provider from its key automatically. - Model ids are OpenCode's native `provider/model` refs, like `anthropic/claude-opus-4-8`. Managed bare ids and `valkor/…` refs do not exist off-gateway. - The model picker shows one list before and after the sandbox boots: the providers your keys connect, plus OpenCode Zen's free models (OpenCode connects those without a key). Thinking effort is the composer's thinking control (the model's own variants); the gateway's Generation defaults do not apply. - The gateway surfaces on this page — managed models, the model-defaults chain, budgets, logs — do not apply; OpenCode resolves the default model in the sandbox. ## Managed models and BYOK A model id has one of three shapes: - **Managed** — a bare id, like `glm-5.2`, `deepseek-v4-pro-0813`, or `grok-4.6`. Valkor supplies the credentials. Cloud accounts pay with Valkor credits. - **BYOK** — a `provider/model` id, like `anthropic/claude-opus-4-8`. You supply the key. Your provider account pays. - **ChatGPT** — a `codex/` id. You connect your ChatGPT plan once through OAuth, and it pays. Connect a BYOK key on the project's Model settings page, or set the provider's env var directly as a [secret](/docs/project/secrets). ## Thinking effort The composer's thinking control sets the session's model **variant** in both modes. The choices are the model's own published tiers (models.dev `reasoning_options`), never a fixed ladder; a model without a knob shows no control. `Auto` clears the variant. - Native (gateway off): OpenCode applies the variant as the provider's own request field. - Gateway on: the request carries `reasoning_effort`; the gateway maps it per upstream (OpenAI → `reasoning_effort`, Claude → adaptive thinking, OpenAI on Amazon Bedrock → Bedrock's `reasoning.effort` request field). For an upstream it cannot map yet (Nova, Grok on Bedrock today) the value is dropped and the model runs at its own default. - Amazon Bedrock refuses the bare in-region id of most current models ("on-demand throughput isn't supported"). The picker prefers the `global.` / regional inference-profile id when the catalog carries one, and the gateway retries a refused bare id once per profile prefix (`global.`, `us.`). - The sandbox learns the project's servable model set from the API at every boot (`GET /v1/llm/models?scope=picker`, the same composition as the web picker), so a model the picker offers always resolves in the runtime. - A project default per model lives in Customize → Gateway → Routing → **Generation defaults** (`model_generation_config`). It fills only a field the request left unset, so a session's variant always wins. ## How auto picks a model Set no model, and Valkor resolves one through five layers, in order. (The id `auto` covers this same behavior, but it is not yet a selectable option in the model picker.) 1. An explicit pin — a session, channel, or trigger's own `model:` field. 2. The [agent's](/docs/project/agents) default for this project. 3. The project's default. 4. The account's default. 5. The platform default. Valkor uses the first layer that has a value it can still serve. A saved default that stops working — a disconnected key, a retired model — is skipped automatically. A session never dies from a stale default. See the [manifest reference](/docs/project/manifest) for the trigger `model:` field. > **Billing surprises on BYOK** > Two costs are easy to miss on a paid cloud account: > > - **Platform fee.** Valkor adds a 10% fee, billed as credits, on top of what > your own provider charges. Free-tier and self-hosted accounts are exempt. > - **Silent failover.** If your BYOK key hits a rate limit or billing error > mid-turn, Valkor retries on a managed model and bills your credits instead > of failing the session. > > If you see credit charges on a BYOK-only project, check these two causes > before reporting a billing bug. ## Per-project model enablement The project controls which models its pickers offer. By default, the newest model of each family is offered automatically. ValKor-managed models and any model your project's defaults or routing policy reference are always offered — a guard never prunes them. You can override the default for individual models on the **Manage models** page (Customize → Models). An exception is stored per project and takes effect immediately. The session model picker and the command palette hide anything you turn off; new models stay on by default as the catalog grows. Enablement governs what is offered, not what is served: a request that names a disabled model outright (for example through the raw API) still runs. The project's default model cannot be turned off — set a different default first. ## Shared, not private, keys A connected provider key applies to the whole project. There is no private, per-user key — setting a personal override for a provider key fails with a `llm_credentials_project_wide` error. Update the shared key on the [secrets](/docs/project/secrets) page instead. --- # Secrets How Valkor stores project credentials, and how it controls whether agent code can read each value. Canonical page: https://beeuni.ai/docs/project/secrets A secret is a per-project credential — an API key, token, or connection string — that a session needs but that must not live in the repository. Valkor stores secrets on the project, never on the account, and encrypts every value with AES-256-GCM using a key derived per project. ## Identifier and name Each secret has two names. The `identifier` is the handle you use in the CLI and in an agent's `secrets` grant. The `name` is the uppercase environment-variable key in the sandbox, for example `STRIPE_API_KEY`. In most projects the identifier and the name match. They differ only when a project holds several candidate values for one name — for example, a primary and a backup Google Maps key can both resolve to `GOOGLE_MAPS_API_KEY`. Every access rule uses the identifier. None of them uses the name. ## Exposure and usage A secret carries two independent settings. Read them separately; they answer different questions. | Setting | Question it answers | Values | |---|---|---| | **Exposure** | Can agent code read the real value? | `environment`, `egress-enforced`, `none` | | **Usage** | Who spends it? | Agent code, LLM gateway, Connector, Git | Older Valkor documentation presented one list that mixed the two. It is gone. There is no choice between a "network boundary" and an "HTTPS broker": one mechanism serves every egress-enforced secret on every sandbox provider. ### The three exposures | Exposure | What the sandbox holds | Use it for | |---|---|---| | **Environment** | The real value, as a plain environment variable | The default. Values the agent must compute with, and protocols that are not HTTPS | | **Egress-enforced** | A **handle** — a self-describing placeholder, worth nothing on its own | Experimental. HTTPS calls, once the `secrets_egress` flag is on | | **None** | Nothing at all | A credential only a Valkor service spends, or a value kept on file and disabled | > **The working rule** > **Environment** is the default exposure. The real value loads into the sandbox, > where the agent can read, print, and forward it. **Egress-enforced** keeps the > value outside the sandbox, but it is experimental: enable the `secrets_egress` > feature flag (Settings → Feature flags) to use it. Until then, a secret loads > into the sandbox environment. ### Usages Most usages are assigned by Valkor, not by you: - **Agent code** — implied whenever exposure is not `none`. - **LLM gateway** — assigned when the value is a recognized model-provider key. The gateway authenticates provider requests server-side. - **Connector:<slug>** — assigned by the connector binding flow. - **Git** — assigned by Valkor for its own Git access. Read-only; you cannot set or clear it. A secret with exposure `none` and no usage renders as **Disabled**: stored, encrypted, and spent by nothing. ## Sent secrets and computed secrets Which exposure a secret can use is a property of the **upstream**, not of Valkor. - **Sent secrets** — the value travels on the wire. API keys, bearer tokens, passwords. This is the vast majority. There is a moment where the value is bytes in an outbound request, so Valkor can put it there itself, outside the sandbox. A sent secret can move to **egress-enforced** once the `secrets_egress` flag is on; by default it loads into the sandbox environment. - **Computed secrets** — the value is an ingredient in a calculation and never travels. AWS SigV4 signing keys, HMAC webhook-signing secrets, JWT client assertions, SSH private keys. Whoever computes must hold the value. No network boundary helps, because nothing on the wire contains the credential. Computed secrets must stay on **environment**. Environment is the default exposure, and the only one that can serve a credential the sandbox has to do math with. Non-HTTPS protocols — a Postgres connection string, SMTP credentials — are in the same position: they must stay on environment. When you save a value that looks like signing material, such as an `AKIA…` access-key pair or PEM/SSH material, Valkor defaults it to **environment** and says why: this key signs requests locally, so egress enforcement cannot apply. ## Egress-enforced exposure > **Experimental — needs the secrets_egress flag** > Enforcement at the network is experimental. Enable the `secrets_egress` feature > flag (Settings → Feature flags) to use it. Until then, a secret loads into the > sandbox environment. With the flag off, creating an egress-enforced secret > returns `403` `feature_disabled`. The sandbox receives an environment variable whose value is a **handle**, not the credential. The agent uses that variable exactly as it would use the real key — in a header, a query string, or a body. `Authorization: Bearer $VAR`, `Cookie: …=$VAR`, an `X-Api-Key` header, a query parameter, and a JSON or form body field all work: Valkor finds the handle wherever it appears (raw, URL-encoded, standalone base64, or JSON-escaped) and swaps in the real value. On the way out, Valkor replaces the handle with the real value, but only for requests to hosts you approved. > **Send the handle as-is — do not base64 it yourself** > One thing does not work: hiding the handle inside a base64 blob you build > yourself, which is what **HTTP Basic auth** does (`curl -u $VAR:` → > `Authorization: Basic `). Valkor cannot find a handle that is embedded, > unaligned, inside base64 you encoded, so the swap never happens and the upstream > answers `401`. Put the handle in a Bearer/token header, a query parameter, or a > body field instead. If the API only supports Basic auth, use **environment** > exposure for that secret. ```text agent's ordinary HTTP client └─▶ in-guest shim (terminates TLS for approved hosts only; holds no secret) └─▶ Valkor, server-side host allow-list → resolve grant and session allowlist → decrypt → substitute handle → call upstream → redact echoes → audit ``` Facts that follow from that shape: - The real value is **never** in the sandbox: not an environment variable, not a file, not an alias. - A handle sent to a host you did not approve arrives as the literal handle string. The upstream rejects it. It is worth nothing. - A handle with a bad signature is never honored, and Valkor records it as a forged handle. A valid handle for a secret this session may not spend is recorded as a stolen one. - The mechanism is identical on every sandbox provider — Daytona, E2B, and Platinum. There is no flag to turn on and no provider to pin. - Every relayed request writes a per-request audit record. - Hosts that are not on the list are tunnelled without being read. Pinned-TLS and mTLS clients to those hosts are unaffected. ### Hosts match exactly List every host you call, one exact hostname per line. | Rejected | Reason | |---|---| | A wildcard host, `*.example.com` | The agent must never choose the destination | | A URL scheme other than HTTPS | Valkor terminates TLS to substitute | `api.example.com` does not cover `uploads.api.example.com`. Add the second host to the same secret. Valkor rejects an unenforceable policy with `400` when you save it — it never stores a rule it cannot apply. Two egress-enforced secrets may share one host. Each handle maps to its own value, so both substitute correctly in the same request. ### Verify it with two probes Run both probes from inside the sandbox, against a host that is on the list. The example uses `postman-echo.com`, which serves one endpoint of each kind. Add it as an allowed host for the duration of the test. ```bash # 1. Reachability — an endpoint that does NOT echo request headers. curl -s -o /dev/null -w '%{http_code}\n' https://postman-echo.com/status/200 # expected: 200 # 2. Substitution — an endpoint that DOES echo request headers. curl -sS -H "authorization: Bearer $STRIPE_API_KEY" https://postman-echo.com/get # expected: 200, with "Bearer [REDACTED]" in the echoed headers ``` Read the pair together, probe 1 first: it is the only one that tells you the host is reachable, and probe 2 proves nothing until it passes. | Probe 2 result | Meaning | |---|---| | `200`, echoed header shows `[REDACTED]` | Working. The real value went upstream and the echo was redacted on the way back. | | `200`, echoed header shows the handle itself | The substitution did not run. Check the host list and the agent grant. | | `401` from a real API host | The substitution did not run. Same two checks. | | An empty reply or a connection error | A real failure. This is not a success symptom. | Confirm the real value is nowhere in the guest: ```bash env | grep '^STRIPE_API_KEY=' # expected: the handle, not the credential ``` The identifier also appears inside `VALKOR_SECRET_CAPABILITIES`, the value-free catalog that tells the agent which secrets exist, which variable holds each handle, and which hosts each one covers. It never carries a value. A host on the list presents a certificate Valkor issued for this sandbox, which the sandbox already trusts, rather than the origin's own: ```bash curl -sv https://postman-echo.com/status/200 2>&1 | grep 'issuer:' # issuer: CN=Valkor Egress CA (); O=Valkor ``` ### What the relay changes An approved host is reached through Valkor, so it behaves a little differently from a direct call: - Responses are not streamed. Server-sent events and websockets do not work through an approved host. - A request body is capped at 1 MiB, a response at 5 MiB, and the whole call at 30 seconds. Valkor follows at most 3 redirects. - Only a fixed set of response headers comes back — content type and language, caching validators, the rate-limit family, `retry-after`, `x-request-id`. - Only clients that honour `https_proxy` are intercepted. Valkor sets that variable, and the matching CA trust, in the agent's environment. A process started with a scrubbed environment calls the host directly, and its request leaves carrying the handle. For a request Valkor cannot intercept, the agent has an explicit door to the same hosts under the same policy: ```bash valkor secrets call STRIPE_API_KEY https://api.stripe.com/v1/customers ``` ## Environment exposure The sandbox receives the real value as a plain environment variable. Agent code can read it, print it, and forward it anywhere. Valkor cannot redact it, cannot audit its use, and cannot stop it leaving. Environment is the default exposure. Every new secret uses it unless you move the secret to egress-enforced, which is experimental and needs the `secrets_egress` flag. A computed credential, and any non-HTTPS protocol, must stay on environment. ## Shared and personal scope A secret is either shared or a personal override: - **Shared** — the project-wide value. Every principal with the `project.secret.read` permission sees every shared secret. Who may read a secret is the permission; which agent receives it is the manifest grant below. - **Personal override** — your own value for one name, used instead of the shared row for sessions you start. Today Valkor uses this only for one OAuth login credential. Valkor never lets an LLM provider key, for example `ANTHROPIC_API_KEY`, become a personal override. The model gateway always reads the shared row. ## Add a secret ### Set the value from the CLI ```bash valkor secrets set STRIPE_API_KEY=sk_live_... ``` Valkor saves it as a shared secret for the project. To store more than one value under the same name, add `--identifier `. Names can't start with `VALKOR_` — Valkor reserves that prefix for platform values. ### Or use the dashboard Open the project's Secrets page, enter the key and value, and save. Valkor encrypts the value immediately and defaults every new secret to **environment** exposure — the real value loads into the sandbox. To move a secret to egress-enforced exposure, first enable the `secrets_egress` feature flag (Settings → Feature flags); it is experimental. With the flag on, the Secrets page shows the **"Can your code read this value?"** control: answering *no* moves the secret to egress enforcement and shows the host list; answering *yes* keeps environment exposure. With the flag off, a secret stays on environment. ### Grant it to an agent Pick an agent on the Secrets page, or add the identifier to that agent's `secrets` list in `valkor.yaml` yourself. A session only receives the secrets its agent is granted. See [Grant a secret to an agent](#grant-a-secret-to-an-agent). ## Grant a secret to an agent A session receives a secret only when the agent it runs names the identifier in its `secrets` list. Matching uses the identifier, not the name, and ignores case. > **Egress-enforced and service usages need a named grant** > `secrets: all` grants environment exposure only. An egress-enforced secret, and > any secret a Valkor service spends, needs its identifier written out in an > agent's list. A project with no `agents:` block in `valkor.yaml` never receives > one. An ungranted secret is dropped silently: the session starts normally and > the first call to the host fails as though the credential were wrong. ### From the dashboard The Secrets page marks a secret no agent can receive: **No agent can receive this secret**. Choose an agent there and confirm. Valkor edits `valkor.yaml` and commits it as `chore(agents): grant to `. The grant works whether or not the manifest already declares that agent. An agent the manifest does not declare gets a new entry holding this one `secrets` list. An agent that is already declared keeps every other field — model, tools, connectors — and the identifier joins its existing list. An agent that already admits the identifier needs no commit, and Valkor makes none. An agent on `secrets: all` is a special case. `all` cannot carry an egress-enforced secret, so Valkor writes an explicit list: every identifier the project has today, plus this one. Nothing the agent receives today changes. A secret you add later needs its own grant. Two cases refuse the grant: - A project on `valkor_version: 1` (`valkor.toml`) has no agents map to edit. The request fails with `400` and `manifest_v1_unsupported`. Edit the manifest by hand, or move the project to `valkor_version: 2`. - A secret that is **Disabled** has nothing to deliver. The request fails with `409` and `secret_not_grantable`. Give it an exposure first. If the project has no `agents:` block yet, read [The first `agents:` block changes the whole project](#the-first-agents-block-changes-the-whole-project) before you confirm. That one edit changes secret access for every other agent. ### By hand The same grant, written directly: ```yaml valkor_version: 2 agents: my-agent: secrets: [STRIPE_API_KEY] ``` ### The first `agents:` block changes the whole project > **Declaring one agent denies the rest** > A project with no `agents:` block — or with no `valkor.yaml` at all — is > ungoverned: every agent receives every environment-exposure secret, and no > agent receives an egress-enforced one. > > The moment the project declares its first agent, every agent that is **not** > listed receives no project secret at all — including environment secrets that > worked a minute earlier. Listing one agent revokes the rest. So list every agent that needs secrets, not only the one you are fixing. This is why the dashboard asks you to confirm the first time: after that commit, `agents:` is the project's allow-list, and an agent missing from it runs with no project secrets. ## Exposing capabilities to third-party users > **Valkor secret policies are not a multi-tenant authorization system** > A secret policy protects **your project's own agent** from leaking your own > credential. It says nothing about which of your end users may spend it. If you are building something where **untrusted third-party users** reach a capability — a public chat surface, a shared app, an agent anyone on the internet can prompt — do not hand them secret policies at all. Every user of that surface shares one project agent, one grant, and one host list. Valkor has no way to tell one of your customers from another, so an egress-enforced secret that any user's prompt can reach is a credential every user can spend, up to the full scope the upstream key carries. Build the boundary you actually need, on your side: 1. Stand up your own authorization and proxy service. It holds the upstream credential. 2. Point the agent at your service, not at the upstream. Give the agent only a credential for your service — that one can be egress-enforced to your own host. 3. Your service identifies the end user, applies your own authorization rules and per-user quotas, and only then makes the upstream call with the credential it holds. That service is where per-user rules belong: who may call what, how often, for which records. Valkor secret exposure is one layer below it, and it does not substitute for it. ## List your secrets Run `valkor secrets ls` to see which secrets a project declares and which ones have a value set. The list is configuration metadata. It never returns secret values. A scoped agent token sees only identifiers in its agent grant. A session-specific `secrets_allowlist` controls delivery into that session, but it does not hide configuration metadata that the agent grant permits. ## Rotate a secret ### Set a new value Run the same command with the new value, or set it again on the project's Secrets page: ```bash valkor secrets set STRIPE_API_KEY=sk_live_new... ``` ### Valkor pushes it to running sessions Valkor pushes the change to every sandbox with an active session for the project, on a best-effort basis. For model or gateway credentials, Valkor restarts the OpenCode compatibility process. Rotating an egress-enforced secret needs no push of the value at all: the sandbox holds a handle, and Valkor reads the current value when the next request comes through. ## Remove a secret Run `valkor secrets unset STRIPE_API_KEY` (or `unset `), or delete it from the project's Secrets page. > **Removal is immediate, propagation is not** > Valkor deletes a shared secret right away. Push to already-running > sandboxes is best-effort, the same as rotation. ## Share a value without seeing it Run `valkor secrets request STRIPE_API_KEY` to create a link. Anyone with the link can enter the value. You never see it. Links stay valid for 7 days by default; adjust with `--expires ` (max 30 days). An expired link shows a clear "expired" page — mint a fresh one with the same command. ## End-to-end example `valkor.yaml`: ```yaml valkor_version: 2 default_agent: my-agent agents: my-agent: secrets: [STRIPE_API_KEY] ``` Secret configuration. The first command stores the value with the default environment exposure. The second moves it to egress-enforced exposure, which is experimental and needs the `secrets_egress` flag (Settings → Feature flags); it returns `403` `feature_disabled` while the flag is off. ```bash valkor secrets set STRIPE_API_KEY=sk_live_... valkor secrets delivery STRIPE_API_KEY egress --allow-host api.stripe.com ``` The agent then calls Stripe with the variable it was given: ```bash curl -s -o /dev/null -w '%{http_code}\n' \ -H "authorization: Bearer $STRIPE_API_KEY" \ https://api.stripe.com/v1/customers # expected: 200 ``` `$STRIPE_API_KEY` holds a handle. Stripe receives the real key, because `api.stripe.com` is on the list. The same request to a host that is not on the list sends the handle, and the upstream rejects it. The same request from an agent whose `secrets` list omits `STRIPE_API_KEY` returns `401`. That session starts normally — an ungranted secret is not an error, it is simply never delivered. ## CLI commands | Command | What it does | |---|---| | `valkor secrets ls` | List secrets declared and set for the project | | `valkor secrets set KEY=VALUE [--identifier ]` | Create or update a secret. `KEY=-` reads the value from stdin | | `valkor secrets unset IDENTIFIER` | Remove a secret | | `valkor secrets delivery IDENTIFIER egress --allow-host ` | Egress-enforced exposure for the listed hosts. Experimental; needs the `secrets_egress` flag | | `valkor secrets delivery IDENTIFIER runtime` | Environment exposure (the default) | | `valkor secrets delivery IDENTIFIER denied` | Disabled | | `valkor secrets call IDENTIFIER URL` | Send one policy-bound HTTPS request through Valkor. Experimental; needs the `secrets_egress` flag | | `valkor secrets sync` | Re-push project secrets to this session's sandbox | | `valkor secrets request NAME [--scope runtime\|connector] [--expires ]` | Create a link so someone else can enter a value | | `valkor env push --from ` | Upload a `.env` file as secrets | | `valkor env pull [--out ] [--force]` | Export secret names, not values, to a `.env` file | The CLI keeps the stored vocabulary: `runtime` is environment exposure, `egress` is egress-enforced, `denied` is disabled. Run `valkor secrets --help` for every flag. Setup links default to `connector`. Use `--scope runtime` only when the agent's shell must receive the value. A secret bound to a connector stays server-side. ## Names and permissions Format rules for the two names: | Name | Format | |---|---| | `identifier` | `^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$` | | `name` | `^[A-Z_][A-Z0-9_]{0,63}$` | Reading or writing a secret needs the `project.secret.read` or `project.secret.write` permission. A project manager holds both. A custom role only adds permissions — Valkor has no deny rule — so no role can withhold either one from a manager. To restrict a manager, remove the manager role. Delivery to a session is the role verdict of the person who started it, intersected with the launched agent's manifest grant. Both must allow the secret. See [One vocabulary, two bindings](/docs/accounts#one-vocabulary-two-bindings). ## REST routes All routes sit under `/v1/projects/{projectId}`. | Method | Path | Description | |---|---|---| | GET | `/secrets` | List secrets. Scoped to the caller's grant if the caller is a scoped agent token. | | POST | `/secrets` | Create or update the shared value. Body: `{name, identifier?, value}`, plus an optional delivery policy. | | PUT | `/secrets/{identifier}/strategy` | Change the exposure and its host list. | | POST | `/secrets/{identifier}/grant` | Add the identifier to one agent's `secrets` list in `valkor.yaml`. Body: `{agent}`. | | DELETE | `/secrets/{name}` | Delete the shared value. Personal overrides stay in place. | | PUT | `/secrets/{name}/personal` | Set or turn on the caller's personal override. | | DELETE | `/secrets/{name}/personal` | Remove the caller's personal override. | `POST /secrets` rejects names that start with `VALKOR_`. It returns `409` if the `identifier` already exists with a different `name`. It rejects the exact name `CODEX_AUTH_JSON` with `400` — Valkor manages that secret through ChatGPT subscription onboarding. Both write routes return a `delivery_sync` object when the change had to reach running sandboxes. `ok: false` means the value is saved but at least one live session still uses the previous one; the listed sessions pick it up on restart. A secret in the list carries `delivery_blocked_reason`. The value `no_agent_grant` means no agent can receive this secret. `null` means it is granted, the exposure needs no grant, or Valkor could not read the manifest. `POST /secrets/{identifier}/grant` clears that reason. It returns `already_granted: true` when the agent's list already admits the identifier, in which case Valkor commits nothing. It returns `adopted_governance: true` when the edit added the project's first `agents:` block — the change [described above](#grant-a-secret-to-an-agent). It answers `400` `manifest_v1_unsupported` for a `valkor.toml` project and `409` `secret_not_grantable` for a disabled secret. ## Rotation and propagation A secret write does not wait for a session restart. Valkor pushes the change to every active sandbox in the project: 1. Valkor builds a new environment snapshot, using the running agent's `secrets` grant. 2. The sandbox writes the snapshot to the live agent environment. New tool calls pick up the change right away. 3. If the changed secret is an LLM provider credential, Valkor restarts OpenCode. This push is best-effort. The API call that changes the secret returns before the push finishes. A failed push is only logged, not retried. A sandbox with a failed push keeps the old value until the next successful push, or until the session restarts. ## Model credentials A project on Valkor's managed model access needs no key of its own. To bring your own, set the provider variables your OpenCode provider config references. A recognized model key is assigned the **LLM gateway** usage, which spends it server-side; it needs no sandbox presence. Do not use a generic provider verification result as runtime proof. It cannot prove the selected model, region, entitlement, and API dialect. Send a real prompt through the exact model. --- # Quickstart Install the CLI, run a session, and merge your first change request. Canonical page: https://beeuni.ai/docs/quickstart This page takes you from a fresh terminal to a merged change request (CR). Every command below is copy-pasteable. ### Install the CLI Run the install script. It downloads the `valkor` binary for your OS. ```sh curl -fsSL https://beeuni.ai/install | bash ``` Valkor supports macOS and Linux. There is no Windows binary yet. ### Sign in ```sh valkor login ``` This opens your browser for sign-in. After you sign in, `valkor login` picks your account and a default [project](/docs/project) for you. ### Create or clone a project A new account has no projects yet, so `valkor login` has none to pick. Run `valkor init` with a name to create your first project. This scaffolds a project directory on your machine. ```sh valkor init my-app cd my-app valkor ship ``` `valkor ship` creates the project in Valkor Cloud on its first run, then pushes your code. Run `valkor ship` again any time to sync local changes. To work on a project that already exists in Valkor Cloud, clone it instead: ```sh valkor projects clone ``` Find `` with `valkor projects ls`. ### Run a session ```sh valkor sessions new --prompt "Build the login page" ``` This starts a [session](/docs/work/sessions). The agent works in its own sandbox, on its own branch, so your project is not touched yet. Attach to the session to watch progress and reply: ```sh valkor sessions chat ``` ### Review and merge the change request When the agent finishes, it opens a change request with a summary and the exact diff. ```sh valkor cr ls ``` This lists the open CRs in your project. Review the one the agent opened, then merge it to land the work on your default branch: ```sh valkor cr merge 1 ``` Replace `1` with the CR number from `valkor cr ls`. Nothing reaches your project's default branch until you merge. ## Next steps - [How the pieces fit](/docs/work) Projects, sessions, and change requests, explained. - [CLI command reference](/docs/cli) Every command, flag, and subcommand. - [The CLI dev loop](/docs/cli) Link a repo, ship code, and manage sessions from your terminal. --- # Apps Create, deploy, and control Valkor Apps from the SDK and from React. Canonical page: https://beeuni.ai/docs/sdk/apps Apps are project-scoped serverless deployments. This page covers the SDK surface: the `apps` facade on a project handle, the artifact and deployment calls, the access calls, the exported types, and the React hooks. For what an App is, the source kinds, the CLI, the stable URL, and cold-wake behavior, read [Apps](/docs/feature-flags/apps). ```ts const apps = valkor.project(projectId).apps; ``` > **Apps is a feature flag** > Every Apps route answers `403` with `{ error, code: "feature_disabled", feature: "apps" }` > until the project turns Apps on. Use `isFeatureDisabledError(error)` to branch > on it. See [Feature flags](/docs/feature-flags). ## The apps facade | Method | Wraps | What it does | |---|---|---| | `apps.list()` | `GET /projects/:pid/apps` | Lists the project's Apps | | `apps.create(input)` | `POST …/apps` | Creates an App and assigns its stable URL | | `apps.get(appId)` | `GET …/apps/:id` | Reads one App | | `apps.update(appId, input)` | `PATCH …/apps/:id` | Renames it or changes machine, idle timeout, or budget | | `apps.remove(appId)` | `DELETE …/apps/:id` | Deletes the App and its runtimes | | `apps.start(appId)` | `POST …/apps/:id/start` | Sets `desired_state` to `running` and warms the runtime | | `apps.stop(appId)` | `POST …/apps/:id/stop` | Suspends compute now; the next request resumes it | | `apps.rollback(appId, deploymentId)` | `POST …/apps/:id/rollback` | Moves traffic to a ready deployment | Artifacts are the immutable input to a deployment: | Method | Wraps | What it does | |---|---|---| | `apps.artifacts.register(input)` | `POST …/apps/artifacts` | Registers an `archive` or an `oci_image`; returns the upload URL for an archive | | `apps.artifacts.uploadArchive(bytes, options?)` | — | Registers, uploads, hashes, and finalizes one `.tar.gz` in a single call | | `apps.artifacts.finalize(artifactId, input)` | `POST …/apps/artifacts/:id/finalize` | Confirms `sha256` and `size_bytes` for a manual upload | Deployments are immutable and numbered: | Method | Wraps | What it does | |---|---|---| | `apps.deployments.create(appId, input)` | `POST …/apps/:id/deployments` | Starts a deployment from an artifact and a source | | `apps.deployments.list(appId)` | `GET …/apps/:id/deployments` | Lists the deployment history | | `apps.deployments.get(appId, deploymentId)` | `GET …/deployments/:did` | Reads one deployment plus its events | | `apps.deployments.logs(appId, deploymentId, options?)` | `GET …/deployments/:did/logs` | Reads runtime logs with a cursor | Access is the App's own authorization policy: | Method | Wraps | What it does | |---|---|---| | `apps.access.get(appId)` | `GET …/apps/:id/access` | Reads the policy. Needs `project.customize.write` | | `apps.access.update(appId, input)` | `PATCH …/apps/:id/access` | Replaces the policy and bumps its revision | | `apps.access.session(appId)` | `POST …/apps/:id/access-session` | Mints a five-minute URL that exchanges into a host-only cookie | ## Deploy a static site `uploadArchive` does the whole artifact handshake: it registers the artifact, checks it against `max_bytes`, `PUT`s the bytes, computes the SHA-256, and finalizes. ```ts const apps = valkor.project(projectId).apps; const app = await apps.create({ slug: 'docs', name: 'Docs' }); const artifact = await apps.artifacts.uploadArchive(tarGzBytes, { onProgress: (uploaded, total) => console.log(`${uploaded}/${total}`), }); const deployment = await apps.deployments.create(app.app_id, { artifact_id: artifact.artifact_id, source: { kind: 'static', spa: true }, }); console.log(app.url, deployment.status); // https://…apps.beeuni.ai queued ``` `create` accepts the machine and budget fields too: `cpu`, `memory_gb`, `disk_gb`, `idle_timeout_seconds`, and `monthly_budget_usd`. Omit them for the defaults. Wait for the deployment by polling its status: ```ts async function waitForReady(appId: string, deploymentId: string) { for (;;) { const { deployment } = await apps.deployments.get(appId, deploymentId); if (deployment.status === 'ready') return deployment; if (deployment.status === 'failed' || deployment.status === 'cancelled') { throw new Error(deployment.error ?? deployment.error_code ?? deployment.status); } await new Promise((resolve) => setTimeout(resolve, 2000)); } } ``` The status values are `queued`, `validating`, `building`, `provisioning`, `checking`, `ready`, `failed`, and `cancelled`. ## Deploy an OCI image Register the immutable image reference, then declare the process command and the public target port: ```ts const registered = await apps.artifacts.register({ kind: 'oci_image', image: 'ghcr.io/acme/service:2026-08-07', }); await apps.deployments.create(app.app_id, { artifact_id: registered.artifact.artifact_id, source: { kind: 'oci_image', image: 'ghcr.io/acme/service:2026-08-07', command: ['node', 'server.js'], port: 3000, readiness_path: '/health', }, }); ``` `register` returns `upload: null` for an `oci_image`. Only an `archive` gets an upload URL. `CreateAppDeploymentInput` also accepts `environment` (non-secret runtime values), `secrets` (runtime key to project secret name), and `provider` (`'daytona' | 'platinum' | 'e2b'`). Omit `provider` to use the server policy. ## Read runtime logs ```ts let cursor = 0; for (;;) { const page = await apps.deployments.logs(app.app_id, deployment.deployment_id, { after: cursor, limit: 200, }); for (const entry of page.entries) console.log(entry.source, entry.line); cursor = page.next_cursor; if (page.entries.length === 0) break; } ``` Each entry carries `cursor`, `time`, `source` (`app`, `appd`, `caddy`), and `line`. ## Your App already knows who is looking An App hosted by Valkor is opened by someone Valkor **already signed in**. The Apps gate authenticates them before your first byte is served, so your App needs no login of its own — no second password, no consent screen, no redirect. In the browser: ```ts import { createValkor, valkorAppViewerToken } from '@valkor/sdk'; import { useValkorAppViewer } from '@valkor/sdk/react'; const valkor = createValkor({ backendUrl: 'https://api.valkor.com/v1', getToken: valkorAppViewerToken(), // the viewer's own App-scoped token }); function Header() { const { status, viewer } = useValkorAppViewer(); return {status === 'viewer' ? viewer.email : 'Signed out'}; } ``` On your App's server, the gate signs the identity into every request: ```ts import { readAppViewer, createAppViewerValkor } from '@valkor/sdk/server'; const viewer = await readAppViewer(request); // { userId, email, groupIds, accountId, appId, accessMode, token } if (!viewer) return new Response('Not found', { status: 404 }); // and, for an `api`-scoped App, act as them: const valkor = await createAppViewerValkor(request, { backendUrl }); await valkor.projects.list(); // their projects, their role ``` `readAppViewer` verifies an HMAC over the header with `VALKOR_APP_VIEWER_SECRET`, which Valkor injects into your App at deploy. A forged header never passes: the gate deletes any client-supplied copy before forwarding, and the signature is made with a secret derived per App. ### How much your App is told One setting on the App's access policy — **Settings → Access** in Valkor, or `viewer_token_scope` on `PATCH /projects/:id/apps/:appId/access`: | Scope | Your App receives | |---|---| | `identity` (default) | The viewer's id, email and group ids, plus a `profile email` token. Enough to show each person their own data. | | `api` | The above, and a token that acts **as** that person on the Valkor API — bounded by their own role. | | `off` | Nothing. | The token is never the user's Valkor session: it lasts an hour, carries only those scopes, and every token an App minted dies when the App is deleted or its access policy changes. `public` and `password` Apps have no signed-in Valkor viewer, so they receive none of this. An App served on its **own domain** (not `*.apps.valkor.com`) has no gate in front of it — use [Sign in with Valkor](/docs/sdk/sign-in) there instead. ## Manage access ```ts await apps.access.update(app.app_id, { mode: 'restricted', member_ids: [memberId], group_ids: [groupId], }); const preview = await apps.access.session(app.app_id); window.open(preview.url); // valid for five minutes ``` `AppAccessConfig` reports `password_configured`, never the password or its hash. Set a password with `{ mode: 'password', password }`. Each update increments `revision`, which revokes existing App cookies. ## Types Every type below is exported from `@valkor/sdk`. | Type | What it holds | |---|---| | `App` | Identity, `url`, `access_mode`, `access_revision`, `desired_state`, `active_deployment_id`, `machine`, `idle_timeout_seconds`, `monthly_budget_usd`, `last_request_at`, `viewer_can_access` | | `AppDeployment` | `version`, `status`, `source_kind`, `hosting_provider`, `runtime_spec`, `build_spec`, `error_code`, `attempt_count`, `created_by`, `actor_type`, `source_session_id` | | `AppDeploymentDetail` | One `deployment` plus its `events` | | `AppAccessConfig` | `mode`, `revision`, `member_ids`, `group_ids`, `password_configured` | | `AppAccessMode` | `'private' \| 'project' \| 'restricted' \| 'public' \| 'password'` | | `AppSource` | `StaticAppSource \| BundleAppSource \| DockerfileAppSource \| OciImageAppSource` | | `AppArtifact` | `kind`, `status`, `sha256`, `size_bytes`, `image_reference` | | `AppLogEntry` · `AppLogsResponse` | One log line, and one page plus `next_cursor` | `viewer_can_access` answers whether the caller may OPEN the App, which is not the same as whether they can see it listed. A project manager sees every App in the project so a private one stays manageable when its creator leaves. Check this field before asking for an access session. Treat `undefined` as unknown, not as denied. `AppAccessMode` is a per-resource visibility setting on top of the role model, not a role. `restricted` names users and groups — the same principal types the role model uses. See [Accounts & access](/docs/accounts#per-feature-access-settings). `DockerfileAppSource` and `OciImageAppSource` require `command` and `port`. `StaticAppSource` and `BundleAppSource` do not. ## React hooks `@valkor/sdk/react` exports three hooks for Apps. ### useProjectApps(projectId) The project's App inventory plus its lifecycle mutations. Every mutation invalidates the inventory on success. ```tsx import { useProjectApps } from '@valkor/sdk/react'; function AppList({ projectId }: { projectId: string }) { const apps = useProjectApps(projectId); if (!apps.data) return null; return (

); } ``` It returns the query fields plus `create`, `update`, `start`, `stop`, and `remove`. ### useAppDeployments(projectId, appId) The immutable deployment history, refetched every 5 s so a running build advances on its own. ```tsx const deployments = useAppDeployments(projectId, appId); await deployments.deploy.mutateAsync({ artifact_id: artifact.artifact_id, source: { kind: 'static', spa: true }, }); await deployments.rollback.mutateAsync(previousDeploymentId); ``` Both mutations invalidate the deployment list and the App inventory. ### useAppAccess(projectId, appId, options?) The access policy and a short-lived access session. Both halves are separate queries, and each one is optional. ```tsx const access = useAppAccess(projectId, appId, { policy: canEditAccess, session: app.viewer_can_access, }); access.policy.data; // AppAccessConfig access.session.data; // { url, expires_at } await access.update.mutateAsync({ mode: 'project' }); ``` | Option | Default | Use `false` when | |---|---|---| | `policy` | `true` | The surface only previews the App. `GET …/access` is an administrative read and answers `403` for a caller without project-manager permissions. | | `session` | `true` | The caller may see the App but not open it. Pass `app.viewer_can_access`. | A grid of Apps that leaves both options at `true` fires one policy read and one session mint per App, and each is a `403` for a member who may not open that App. ## Errors ```ts import { featureDisabledKey, isFeatureDisabledError } from '@valkor/sdk'; try { await valkor.project(projectId).apps.list(); } catch (error) { if (isFeatureDisabledError(error)) { console.log(`${featureDisabledKey(error)} is off for this project`); } } ``` Other answers you should handle: `409` for a duplicate slug, `402` with `app_quota_exceeded` when the account is at its App limit, and `400` with `app_machine_out_of_range` or `app_budget_out_of_range` for a spec outside its bounds. --- # Authentication Authenticate the SDK with a personal access token or a service account. Canonical page: https://beeuni.ai/docs/sdk/auth Valkor accepts one bearer token per request. Pass it through `getToken` in `createValkor`. The SDK sends it as `Authorization: Bearer `. ```ts import { createValkor } from '@valkor/sdk'; const valkor = createValkor({ backendUrl: 'https://api.valkor.com/v1', getToken: async () => process.env.VALKOR_API_KEY!, }); ``` `backendUrl` and `getToken` are required. The SDK caches nothing: it calls `getToken` on every request, so your app owns token storage and refresh. Set `clientSource` to `api`, `cli`, `mobile`, or `web` when your host needs a separate source in the centralized audit log. This value identifies the client surface. It does not change the authenticated actor or their permissions. ## Personal access tokens A personal access token (PAT) is the credential for the SDK, the CLI, and CI. Create one in your own settings, at **Settings → API keys** (`/settings/tokens`). The key starts with `valkor_pat_` and shows only once, at creation. Store it as a secret. A PAT acts as the user who created it and holds exactly that user's role assignments. It adds no access of its own. Its scope only narrows the reach: | Scope | Reach | |---|---| | Account (default) | Every project in the account | | Project | One project only; every other project returns `403` | Choose the project scope for CI and other narrow-purpose credentials. `valkor login` mints a PAT and stores it locally — it is the same credential type, not a separate token kind. ## Service accounts A service account is a separate credential family for non-human callers, prefixed `valkor_sa_`. Create one at **Account → Tokens** (`/accounts/?tab=tokens`), the account-level surface for credentials that are not a person's. A service account is its own **principal** (`service_account`), not a person's credential. It has no membership, so it holds only the roles assigned to it directly. An agent's identity is a service account, which is how you assign a role to an agent. See [Accounts & access](/docs/accounts#one-access-model). > **Warn** > A new service account has no assignments and therefore no project access. If > you point `getToken` at one before you assign it a role, every call returns > `403 "You do not have access to this project"`. Assign it a project role > first, or use a personal access token for the SDK, the CLI, and demos > instead. ## OAuth access tokens (Sign in with Valkor) A third-party app that signs users in through Valkor receives a `valkor_oat_` token per user. With the `valkor` scope it acts as that user on the whole API, exactly like a personal access token, but it expires after an hour and rotates through a refresh token. `createValkorAuth` in `@valkor/sdk/server` owns the whole lifecycle — see [Sign in with Valkor](/docs/sdk/sign-in). ## Supabase JWT If your app uses Valkor's own sign-in, return the live session token instead of a PAT: ```ts getToken: async () => (await supabase.auth.getSession()).data.session?.access_token ?? null, ``` The SDK calls `getToken` on every request, so a refreshed token takes effect automatically. ## Headless sign-in (email, password, magic link, social) Every ordinary sign-in flow is available through the Valkor API, so a CLI, a native app, a script, or your own backend signs users up and in without a Supabase URL or key — on valkor.com and on a self-host alike. ```ts import { createValkor } from '@valkor/sdk'; const session = createValkor({ backendUrl, getToken: async () => null }).auth.session({ storage: { // optional: any get/set/remove get: () => localStorage.getItem('valkor'), set: (v) => localStorage.setItem('valkor', v), remove: () => localStorage.removeItem('valkor'), }, }); const valkor = createValkor({ backendUrl, getToken: session.getToken }); // refreshes itself const { session: s, user } = await valkor.auth.signInWithPassword({ email, password }); await session.set(s, user); await valkor.projects.list(); // as that user ``` | Call | Route | Notes | |---|---|---| | `auth.signUp({ email, password, redirect_to? })` | `POST /v1/auth/signup` | `requires_email_confirmation: true` → no session until the emailed link/code is used. | | `auth.signInWithPassword({ email, password })` | `POST /v1/auth/sign-in/password` | | | `auth.sendMagicLink({ email, redirect_to? })` → `auth.verifyOtp({ email, token, type: 'magiclink' })` | `/sign-in/magic-link`, `/verify-otp` | The email carries a link and a 6-digit code. | | `auth.signInWithProvider({ provider, redirect_to })` → `auth.exchangeCode({ code, code_verifier })` | `/sign-in/oauth`, `/oauth/exchange` | PKCE: keep `code_verifier` until the provider redirects back with `?code=`. `redirect_to` must be on the instance's redirect allow-list. | | `auth.refresh({ refresh_token })` | `POST /v1/auth/refresh` | `createValkorSession` calls it for you. | | `auth.resetPassword({ email, redirect_to? })` → `auth.verifyOtp({ type: 'recovery' })` → `auth.updatePassword({ password }, token)` | `/password/reset`, `/verify-otp`, `/password/update` | | | `auth.user(token)` / `auth.signOut(token)` | `GET /v1/auth/user`, `POST /v1/auth/sign-out` | Sign-out revokes at Supabase and in the Valkor session gate. | Errors throw `HeadlessAuthError` with `code`, `message` and the upstream `status` (`invalid_credentials`, `over_request_rate_limit`, …). Each route is limited to 30 attempts per minute per IP. Multi-factor enrolment/challenge is not on the API yet — it stays on the Valkor web app. ## Choose a credential | You are building | Use | |---|---| | A backend, script, or CI job | A personal access token, account-wide or project-scoped | | The Valkor CLI | `valkor login` (mints a personal access token) | | A web app, CLI, or native app signing users in itself | `valkor.auth.*` + `createValkorSession` (headless sign-in, above) | | An automated caller with its own project assignment | A service account | | Your own app, signed in by its users with their Valkor account | [Sign in with Valkor](/docs/sdk/sign-in) — an OAuth access token the SDK manages for you | --- # Full example One file that lists projects, starts a session, and streams a reply. Canonical page: https://beeuni.ai/docs/sdk/example This page shows the OpenCode REST compatibility SDK path in one file. It uses no framework and needs no build step beyond TypeScript. Use [`useSession`](/docs/sdk/react) for a React surface that owns the whole session lifecycle. ## The complete script ```ts import { ApiError, classifyTurn, createValkor, narrowChatEvent } from '@valkor/sdk'; import type { MessageWithParts } from '@valkor/sdk'; async function main() { // 1. One client, one auth seam. getToken returns your API key // (valkor_pat_…) or a logged-in user's Supabase JWT — nothing else. const valkor = createValkor({ backendUrl: 'https://api.valkorai.com/v1', getToken: async () => process.env.VALKOR_API_KEY!, }); // 2. Platform REST: list projects, pick one (or provision your first). const projects = await valkor.projects.list(); const project = projects[0] ?? (await valkor.projects.provision({ name: 'sdk-quickstart' })); console.log(`using project ${project.name} (${project.project_id})`); // 3. Create a session — a cheap platform call. No sandbox exists yet. const created = await valkor.projects.createSession(project.project_id, { name: 'sdk full example', }); const session = valkor.session(project.project_id, created.session_id); // 4. Ready the session. This provisions (or resumes) the real cloud // sandbox. ensureReady() polls /start (each call long-polls up to 30s) // until the runtime is ready or its deadline (~3 min) elapses, so a // cold boot just takes longer rather than throwing. The // retryUntilReady wrapper below is optional — keep it only if you want // a longer total budget than the default. const { opencodeSessionId } = await retryUntilReady(() => session.ensureReady()); // 5. Connect the event stream before you send, so no early events are // missed. narrowChatEvent() collapses the wire events into a small // typed union you can switch over. let resolveIdle!: () => void; const idle = new Promise((resolve) => (resolveIdle = resolve)); const stream = await session.stream({ onEvent: (event) => { const e = narrowChatEvent(event); if (!e) return; if (e.type === 'message.part.updated') process.stdout.write('.'); if (e.type === 'session.error') console.error('\nerror:', e.error); if (e.type === 'session.idle' && e.sessionID === opencodeSessionId) { resolveIdle(); // the turn is finished } }, }); // 6. Send. Per-send overrides pick the model and the agent for this // prompt only (ids come from projects.modelPicker() and // projects.detail().config.agents). await session.send('What files are in this repo?', { model: { providerID: 'valkor', modelID: 'glm-5.2' }, }); // 7. Wait for the turn to finish — the session.idle event, not a sleep. await idle; stream.close(); // 8. Render the transcript. classifyTurn() turns the wire part variants // into one union, so a renderer can switch on part.kind and // TypeScript proves no case is missed. const result = await session.runtime.session.messages({ sessionID: opencodeSessionId, }); for (const message of (result.data ?? []) as MessageWithParts[]) { for (const part of classifyTurn(message).parts) { if (part.kind === 'text') console.log(`\n[${message.info.role}] ${part.text}`); } } } /** Optional outer-budget wrapper — ensureReady() already polls internally. */ async function retryUntilReady(ensure: () => Promise): Promise { 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)); } } } main().catch((error) => { console.error(error); process.exit(1); }); ``` Run it with Node 18 or later, Bun, or `tsx`: ```sh VALKOR_API_KEY=valkor_pat_... npx tsx full-example.ts ``` The first `ensureReady()` call on a fresh session provisions a real cloud sandbox, so the ready step takes a while on the first run. Later runs resume the same sandbox and finish fast. ## What each step teaches | Step | Concept | Deep dive | | ---- | ---------------------------------------------------- | ----------------------------------- | | 1 | One client, one token, one auth seam | [Authentication](/docs/sdk/auth) | | 2–3 | The platform REST surface: projects and sessions | [Reference](/docs/sdk/reference) | | 4 | Session readiness, the bridge from platform to runtime | [Sessions](/docs/sdk/sessions) | | 5, 7 | Live SSE events, `narrowChatEvent`, `session.idle` | [Sessions](/docs/sdk/sessions) | | 6 | Per-send `{ model, agent }` overrides | [Sessions](/docs/sdk/sessions) | | 8 | `classifyTurn` and the exhaustive part union | [Reference](/docs/sdk/reference) | ## Going further from here The same client reaches the rest of the platform through one facade. ```ts const project = valkor.project(projectId); // Workspace files inside the session's live sandbox const tree = await session.files.list('/workspace'); const readme = await session.files.read('/workspace/README.md'); // Runtime secrets are readable by selected sessions inside the sandbox. await project.secrets.upsert({ name: 'LOCAL_TOOL_TOKEN', value: 'secret-value', strategy: 'runtime', consumer: 'sandbox', }); // Managed provider credentials stay on the Valkor LLM gateway. await project.secrets.upsert({ identifier: 'anthropic-primary', name: 'ANTHROPIC_API_KEY', value: 'sk-ant-…', strategy: 'broker', consumer: 'llm_gateway', }); // The project's agents and skills (config files in the repo) const { config } = await valkor.projects.detail(projectId); console.log( config.agents.map((a) => a.name), config.skills.length, ); // LLM gateway observability — cost, latency, per-model breakdown const overview = await project.gateway.overview(7); const routing = await project.gateway.routing.get(); ``` The package ships runnable examples that cover each of these steps, in `packages/sdk/examples/`. They include a minimal client, streaming, a server wrapper, a Valkor-as-a-Backend multi-tenant wrapper, transcript rendering, and files and secrets. --- # TypeScript SDK Install, authenticate, and send your first message with the typed SDK. Canonical page: https://beeuni.ai/docs/sdk `@valkor/sdk` is the typed client for the Valkor platform. It wraps the Valkor REST API and the agent runtime in one interface. Your app never calls a session transport directly. The core client is fetch-based and runs in Node, Bun, and browsers. ## Install ```bash npm install @valkor/sdk ``` `react` (18+) and `@tanstack/react-query` (5.75+) are optional peers, needed only for [React hooks](/docs/sdk/react). ## Create a client Call `createValkor` once, with your API base URL and a function that returns your token. ```ts import { createValkor } from '@valkor/sdk'; export const valkor = createValkor({ backendUrl: 'https://api.valkorai.com/v1', getToken: async () => process.env.VALKOR_API_KEY!, }); ``` `backendUrl` and `getToken` are the only required fields. The SDK calls `getToken` on every request and caches nothing — your host owns token storage and refresh. Create an API key at **User settings → API keys**. The key starts with `valkor_pat_` and shows only once. Store it as a secret and return it from `getToken`. See [Auth](/docs/sdk/auth) for token types and scopes. ## Start your first session 1. Create a session in your project. ```ts const created = await valkor.project(projectId).sessions.create(); const session = valkor.session(projectId, created.session_id); ``` 2. Wait for the sandbox to accept work. ```ts await session.ensureReady(); ``` `ensureReady()` starts or resumes the session sandbox. It polls the session's `/start` endpoint — each call long-polls up to 30 s — until the runtime is ready, hits a terminal stage, or its deadline elapses (default ~3 min, configurable via `{ readyTimeoutMs }`). On a cold boot it keeps polling while the sandbox reports `retriable: true`; it only throws an `ApiError` with `code: 'RUNTIME_UNAVAILABLE'` if the runtime is still not ready when the deadline expires. See [Sessions](/docs/sdk/sessions). 3. Send a message to the agent. ```ts await session.send('Add a README'); ``` `send()` calls `ensureReady()` for you, then sends the message. `createValkor` gives you an imperative client: call methods for every action, like projects, sessions, secrets, and triggers. `@valkor/sdk/react` gives you hooks for live UI data — `useSession` runs a whole session in one hook. - [Full example](/docs/sdk/example) Zero to a streaming agent reply. - [Auth](/docs/sdk/auth) API keys and Supabase JWTs. - [Sessions](/docs/sdk/sessions) Lifecycle, streaming, and error handling. - [React hooks](/docs/sdk/react) `useSession` and other reactive hooks. - [Reference](/docs/sdk/reference) The full client, modules, turns, and distribution. --- # React hooks Run a Valkor session in React with the useSession hook. Canonical page: https://beeuni.ai/docs/sdk/react `@valkor/sdk/react` adds React hooks on top of the SDK. This page covers `useSession`, the hook that runs a session end to end, and the other hooks confirmed stable for React apps. ## useSession(projectId, sessionId, options?) `useSession` starts the session, opens the server-selected event transport, and syncs messages, status, and pending prompts. Call it once per session view. ```tsx import { useSession } from '@valkor/sdk/react'; function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) { const s = useSession(projectId, sessionId); if (s.phase !== 'ready') return ; return ( <> {s.messages.map(({ info, parts }) => ( ))} ); } ``` Readiness is server truth. The runtime is ready when `POST /start` returns `stage: 'ready'`. `useSession` does not run a separate client-side health check. ### Returns | Field | Type | What it holds | |---|---|---| | `phase` | `'starting' \| 'ready' \| 'error'` | Overall state. Render a boot screen until `ready`. | | `messages` | `{ info, parts }[]` | The message list. Parts stream in live. | | `status` | `SessionStatus` | The session status. | | `isBusy` | `boolean` | The agent is generating a reply. | | `questions`, `permissions` | array | Pending agent questions and tool-approval requests. A `permission` here is one runtime tool approval, not an IAM permission. | | `diffs`, `todos` | array | Live file diffs and todo items. | | `sendError` | `ValkorSendError \| null` | The last `send` failure: `billing`, `runtime-not-ready`, or `runtime-error`. | | `rewindMessageId` | `string \| null` | The selected user message while a reversible rewind is staged. | | `rewindPending` | `boolean` | A rewind or restore request is in progress. | | `rewindError` | `ValkorSendError \| null` | The last rewind or restore failure. | | `models`, `agents`, `defaultAgent`, `commands` | — | Selectable models, selectable agents, the default agent, and slash commands. Available before the runtime starts. | | `retry` | `() => void` | Force a re-check of `/start`. | ### Actions | Action | What it does | |---|---| | `send(text, override?)` | Send a prompt. `override` sets `{ model?, agent? }` for this message only. | | `sendParts(parts, override?)` | Send text and file prompt parts through the selected transport. | | `rewind(messageId)` | Rewind this canonical session to a user message. The selected message and later path become hidden and recoverable. | | `restoreRewind()` | Restore the removed path before another prompt commits its replacement. | | `cancel()` | Stop the current run and clear pending questions and permissions. | | `runCommand(command, args)` | Run a project slash command. | | `answerQuestion(id, answers)` | Answer a pending agent question. | | `rejectQuestion(id)` | Reject a pending agent question. | | `answerPermission(id, reply, message?)` | Answer a tool-approval request. `reply` is `'once'`, `'always'`, or `'reject'`. | `useSession` also returns `removeQuestion` and `removePermission`. Do not use them. They clear the prompt from local state but never notify the agent, so the run stays blocked. Use `answerQuestion`, `rejectQuestion`, or `answerPermission` instead. ### Options | Option | Default | What it does | |---|---|---| | `waitMs` | `15000` | The long-poll budget sent to `/start`. | | `replayStartStash` | `true` | Replay a prompt saved before the session existed, once the session is ready. | | `enabled` | `true` | Set `false` to delay the hook, for example until a billing check passes. | | `chatEngine` | `true` | Set `false` if your app mounts its own chat surface for this session, to avoid syncing messages twice. | Sending is optimistic. `send` shows your message right away, then stream events fill in the agent's reply. `rewind(messageId)` never creates a session. It uses the canonical session from `POST /start`. The runtime restores file state and keeps the removed transcript path recoverable. The next accepted prompt commits the replacement path. ## Other stable hooks `@valkor/sdk/react` also exports React Query hooks for data that does not need a running session. Each mirrors a method on the [client](/docs/sdk/reference) and needs no provider. | Hook | Reads | |---|---| | `useProjectModels(projectId)` | Selectable models for the project. | | `useProjectModelPickerCatalog(projectId)` | The raw `/model-picker` record per wire model (`reasoning_options`, `temperature`, `limit`) for capability-gated controls. | | `useVisibleAgents({ projectId })` | The project's visible agents. | | `useProjectConfig(projectId)` | The project's runtime config: default agent, commands. | | `useProjectSecrets(projectId)` | Secrets: list, add, remove, and personal overrides. | | `useProjectTriggers(projectId)` | Triggers: list, create, update, remove, fire. | | `useChangeRequests(projectId, status?)` | Change requests: list, open, merge, close, request changes. | ## Next - [Sessions](/docs/sdk/sessions) — the session handle `useSession` wraps, and the `ValkorSendError` kinds. - [Reference](/docs/sdk/reference) — the full REST surface these hooks read from. --- # SDK reference The full @valkor/sdk API surface — client methods, modules, turns, and distribution. Canonical page: https://beeuni.ai/docs/sdk/reference This page is the full `@valkor/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 `createValkor(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 valkor = createValkor({ backendUrl, getToken }); valkor.accounts; // account / team operations valkor.accountInvites; // invite accept/decline by token alone valkor.projects; // top-level project operations valkor.connectors; // Connector calls scoped by an agent-minted token valkor.project(id); // id-bound project handle valkor.session(pid, sid); // id-bound session handle → see Sessions valkor.github; // GitHub App install + repo linking valkor.billing; // credits, subscription, tier, transactions valkor.sandboxShares; // public share links for a sandbox port valkor.connectStatus; // easy-connect (Pipedream) status valkor.marketplace; // public marketplace catalog valkor.validateToken; // pasted-API-key check valkor.config; // platform config in effect valkor.runtime(); // OpenCode REST compatibility client ``` ### Accounts — `valkor.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 (`valkor_pat_...`). See [SDK auth](/docs/sdk/auth) 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 Valkor 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 — `valkor.accountInvites` Reached by invite token alone — the invitee may not be a member yet. | method | what | | --- | --- | | `describe(inviteId)` · `accept(inviteId)` · `decline(inviteId)` | preview · accept · decline an invite | ### Projects — `valkor.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 Valkor-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. ### GitHub — `valkor.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 | | `listRepositories(accountId, installationId?)` · `listRepositoryBranches(...)` | repos the install can see · branches and the GitHub default | | `linkRepository(input)` | import a repo as a project | ### Billing — `valkor.billing` Reads for credits, subscription, tier, and transaction history. Checkout, the customer portal, and credit purchases are Stripe flows, app-owned. | 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?)` | start · confirm a Stripe Checkout session | | `subscription.createPortalSession(...)` · `subscription.cancel(...)` · `subscription.reactivate(...)` | open the customer portal · cancel · reactivate | | `subscription.scheduleDowngrade(...)` · `cancelScheduledChange(...)` · `prorationPreview(...)` | schedule · cancel · preview a plan change | | `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 — `valkor.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 — `valkor.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 | ### The project handle — `valkor.project(id)` Binds the project id; every sub-resource hangs off it. ```ts const p = valkor.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 `valkor.yaml` (or legacy `valkor.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 `VALKOR_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.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 Valkor 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. Valkor 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.connectors` — tool connectors | method | what | | --- | --- | | `catalog()` · `tools()` | callable Connector catalog · flattened `.` tools | | `search(query, options?)` · `describe(tool)` | find · inspect one callable tool | | `call(tool, args?)` | call one `.` tool through the server-side gateway | | `uploadAttachment(content, input)` | upload bytes and receive an opaque attachment handle for a later call | | `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)` | select `project` or `user` connection ownership | | `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 | `p.connectors.discover` browses the direct-connector catalog. It is **experimental** and off by default — enable it per project under [Settings → Experimental](/docs/feature-flags) → "Connectors API Discover". Easy Connect (Pipedream) 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 optional managed-OAuth path; `listApps` returns OAuth apps only. Connect API-key apps directly instead. | 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, authorization strategy, and policies. `connections` stores its connected accounts. A session can select one with `connector_bindings: { alias: { connection_id } }`. 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. #### `p.policies` — project policies | method | what | | --- | --- | | `list()` · `set(policies)` | the project's policies · replace the set | #### `p.triggers` — cron and webhook automations A trigger starts an agent action on a schedule or an inbound webhook. See [Triggers](/docs/connect/triggers) for session strategy and payload templating. | method | what | | --- | --- | | `list()` | all triggers | | `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', prompt_template, slug?, agent?, model?, enabled?, session_mode?, session_id?, cron?, run_at?, timezone?, secret_env?, 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'`. `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 `valkor.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 | | --- | --- | | `list(options?)` · `read(path, ref?)` | the repo tree · a file's contents at a git ref | | `search(query)` | search the repo | | `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 `valkor.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 and `require_connectors` for mandatory connectors. #### `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`. #### `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 `valkor.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 `@valkor/sdk`, typed as `FeatureFlagKey`). The caller needs the project's `project.customize.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`) | ### Escape hatch `valkor.runtime()` returns the typed OpenCode REST client for the active sandbox. On a client created by `createScopedValkor` (`@valkor/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 `valkor.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 `@valkor/sdk`. Use this for all new code. | | Supported | A dedicated subpath (`@valkor/sdk/react`, `@valkor/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 `@valkor/sdk`: ```ts import { files, getSessionHealth, getClient, authenticatedFetch, backendApi } from '@valkor/sdk'; ``` ### Canonical modules | Module | What it does | | --- | --- | | Files | Workspace file operations: list, read, search, write | | Session runtime | Health probe and preview/proxy URL builders | | OpenCode client | The typed OpenCode 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 | #### Files ```ts import { files } from '@valkor/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'); ``` `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 '@valkor/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`. #### OpenCode client ```ts import { getClient } from '@valkor/sdk'; const client = getClient(); const { data } = await client.session.list({ limit: 100 }); ``` `getClient()` returns the typed OpenCode v2 compatibility client for the active sandbox, with auth already injected. Prefer `valkor.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 '@valkor/sdk'; const res = await authenticatedFetch(`${runtimeUrl}/valkor/health`); const token = await getAuthToken(); ``` The token comes from the `getToken` function you passed to `createValkor`. Most app code does not need this module — the file, session, and facade layers already authenticate for you. #### API client ```ts import { backendApi } from '@valkor/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. ### Supported subpaths | Subpath | What it does | | --- | --- | | `@valkor/sdk/react` | React hooks — see [React hooks](/docs/sdk/react) | | `@valkor/sdk/server` | Request-scoped config for multi-tenant backends | #### Server-side isolation `createValkor` 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 `createValkor` call wins for every in-flight request. `@valkor/sdk/server` fixes this with per-request isolation: ```ts import { createScopedValkor } from '@valkor/sdk/server'; export async function handler(req: Request) { const valkor = createScopedValkor({ backendUrl, getToken: () => tokenFor(req) }); return valkor.projects.list(); } ``` `createScopedValkor` and `runWithValkor` isolate config per request with Node's `AsyncLocalStorage`. Never import `@valkor/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 `valkor.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 `@valkor/sdk` entry already exports. They stay working so no existing ```ts import { classifyPart, classifyTurn, toolInfo, toolViewModel } from '@valkor/sdk'; ``` ```ts import { classifyPart, type ClassifiedPart } from '@valkor/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; } } ``` ```ts interface ToolView { name: string; title: string; status: 'pending' | 'running' | 'done' | 'error'; input?: Record; output?: string; error?: string; outputParsed?: unknown; // JSON.parse(output) when it parses, capped at 256KB outputText?: string; // the raw output text, always present } ``` ```ts toolInfo('bash'); // { label: 'Shell', category: 'shell' } getToolInfo('write', { filePath: '/workspace/main.go' }); // { icon: 'file-pen', title: 'Write', subtitle: 'main.go /workspace' } ``` ```ts const vm = toolViewModel(classifiedTool); if (vm.kind === 'shell') { render(vm.command, vm.stdout, vm.exitCode); } ``` ```ts import { groupMessagesIntoTurns, collectTurnParts, type TurnLike } from '@valkor/sdk'; const turns: TurnLike[] = groupMessagesIntoTurns(messages); for (const turn of turns) { const parts = collectTurnParts(turn); } ``` ```ts import { isTextPart, isToolPart, getPartText } from '@valkor/sdk'; if (isTextPart(part)) { // part.type narrowed to 'text' } const text = getPartText(part); // works for 'text' and 'reasoning' parts ``` ```ts import { getWorkingState, getTurnStatus, formatDuration } from '@valkor/sdk'; const status = getTurnStatus(parts, childMessages); // "Running commands..." formatDuration(4300); // "4s" — durations under 1s return '' ``` ```ts import { getTurnError, getChildSessionError, unwrapError } from '@valkor/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 ``` ```ts import { getTurnCost, getSessionCost, formatCost, formatTokens, COST_MARKUP } from '@valkor/sdk'; const info = getTurnCost(partsWithMessage, modelPricingLookup); const sessionCost = getSessionCost(messages, modelPricingLookup); formatCost(0.0032); // "$0.003" formatTokens(12345); // "12k" ``` ```ts import { getChildSessionId, getChildSessionToolParts } from '@valkor/sdk'; const childId = getChildSessionId(taskToolPart); const steps = getChildSessionToolParts(childMessages); ``` ```ts import { getPermissionForTool, getHiddenToolParts, isToolPartHidden } from '@valkor/sdk'; const permission = getPermissionForTool(permissions, callID); const hidden = getHiddenToolParts(activePermission, activeQuestion); ``` ```ts import { getFilename, getDirectory, relativizePath, stripAnsi } from '@valkor/sdk'; getFilename('/workspace/src/main.go'); // "main.go" getDirectory('/workspace/src/main.go'); // "/workspace/src" relativizePath('/workspace/src/main.go', '/workspace'); // "src/main.go" ``` ```ts import { sortSessions, childMapByParent, allDescendantIds } from '@valkor/sdk'; sessions.sort(sortSessions(Date.now())); // pins sessions updated in the last 60s const childMap = childMapByParent(sessions); const descendants = allDescendantIds(childMap, sessionId); ``` ```sh npm install @valkor/sdk ``` ```ts import { createValkor } from '@valkor/sdk'; // framework-free core import { useSession } from '@valkor/sdk/react'; // optional React layer import { createScopedValkor } from '@valkor/sdk/server'; // Node and Bun servers ``` ```html ``` --- # Sessions Run a session, stream its events, and handle the errors it can throw. Canonical page: https://beeuni.ai/docs/sdk/sessions A session is one agent run, in its own sandbox, on its own git branch. `valkor.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. ```ts const s = valkor.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](/docs/work/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 or updates metadata | | `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.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.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 connector authorizations | | `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 | > **Warn** > `s.delete()` deletes the session and its runtime. This cannot be undone. To pause a session > without losing it, call `s.stop()` instead. Use the streamed method when the caller displays reload progress: ```ts 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. 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.audit(limit?)` — the session's audit trail of agent actions. - `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.voiceTranscript(options?)` — this session's live voice-call transcript (spoken turns plus `ask_valkor`/`run_command` worker tool calls). Returns an empty list when the session has no live call, not a 404. ## 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. ```ts const { opencodeSessionId, runtimeUrl, sandboxId } = await s.ensureReady(); ``` On a cold boot, `ensureReady()` can throw `RUNTIME_UNAVAILABLE`. See [Retry on a cold boot](#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 OpenCode pin A server-rendered React host can supply the OpenCode pin already persisted for the same Valkor session: ```tsx const session = useSession(projectId, sessionId, { initialOpenCodeSessionId: 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. Do not accept this value from an untrusted tenant selector. Do not create an OpenCode session in the host. Valkor creates and persists the root session. OpenCode query caches and transcript controllers are scoped to the sandbox runtime, so equal OpenCode ids from different sandboxes do not share cache entries. ## Send a prompt ```ts 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 ``` For OpenCode REST sessions, the first `send()` on a handle reads the model and agent persisted on the Valkor session. This prevents a snapshot-inherited OpenCode 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 Valkor 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`: ```ts 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 connector authorizations. `secrets_allowlist: null` means the agent's secret grant applies: ```ts 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: ```ts await s.rescope({ secrets: ['DATABASE_URL'], connector_bindings: { github: { authorization_id: authorizationId }, }, }); ``` Each supplied field replaces its complete previous value. Omit a field to leave it unchanged. Connector authorization 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: ```ts 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: ```ts 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 | ```ts 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](#session-readiness-errors). ## Streaming Use `s.stream()` to receive live events in a script or server. In a React app, use [`useSession`](/docs/sdk/react) instead — it manages the whole session lifecycle for you. `s.stream()` is the OpenCode REST compatibility event stream. The Valkor 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. In React, prefer `useSession()`. It owns start, readiness, the stream, and message sync for you. 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`. ```ts const session = valkor.session(projectId, sessionId); const { opencodeSessionId } = await session.ensureReady(); const stream = await session.stream({ onEvent: (event) => { if (event.type === 'session.idle' && event.properties.sessionID === opencodeSessionId) { onTurnDone(); stream.close(); } }, }); await session.send('Refactor the auth module'); ``` Streaming needs `fetch` with a real `ReadableStream` body and `TextDecoderStream`. Browsers, Node 18 and later, Bun, and Cloudflare Workers all support it. React Native and Expo do not: their `fetch` has no `response.body`. On React Native, use `createHttpSessionSyncController` for bounded history and status synchronization. Use a platform-specific event transport for live events. 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](/docs/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`. ```ts async function retryUntilReady(ensure: () => Promise): Promise { 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](#error-classes) for the full `ApiError` shape. In React, [`useSession`](/docs/sdk/react) 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. ```jsonc { "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. ## 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](/docs/sdk/reference) for the full method list. ## The raw runtime `s.runtime` is the typed OpenCode REST compatibility client. Use it only for OpenCode REST calls that `send`, `abort`, and `stream` do not cover. It requires a resolved OpenCode runtime — call `s.ensureReady()` first. ```ts const { opencodeSessionId } = await s.ensureReady(); await s.runtime.session.prompt({ sessionID: opencodeSessionId, parts: [{ type: 'text', text: 'Refactor the auth module' }], }); ``` The OpenCode `sessionID` here is not the session ID you pass to `valkor.session(projectId, sessionId)`. The SDK resolves it during `ensureReady()` and caches it on the handle. ## Warm a project session Use the server-owned warm-session operations when a project landing page needs one empty runtime before the first prompt. ```ts const project = valkor.project(projectId); const warm = await project.sessions.ensureWarm(); await valkor.session(projectId, warm.session.session_id).ensureReady(); const claimed = await project.sessions.claimWarm({ session_id: warm.session.session_id, agent_name: selectedAgent, sandbox_slug: selectedSandbox, }); ``` `ensureWarm()` creates or reuses one available session for the current user. `claimWarm()` atomically reserves that session before navigation or prompt delivery. A `409` response means another client claimed it or the selected agent or sandbox differs. ## Handling errors Every call through `createValkor` rejects with a typed `Error` subclass, never a plain object. Catch the error, check `instanceof`, and branch on `.status` or `.code`. ```ts import { ApiError, BillingError } from '@valkor/sdk'; try { await valkor.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`. Valkor never sent the request | `code` is always `'NO_SESSION'` | | `BillingError` | `Error` | HTTP `402`. The only billing error class | `status` (`402`), `detail.message` | | `RequestTooLargeError` | `Error` | 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. Valkor retries some requests before your code sees an error. If a `GET` or `HEAD` request returns `502`, `503`, or `504`, Valkor 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`. Valkor never retries `POST`, `PUT`, `PATCH`, or `DELETE` requests, or a `500` response. Valkor 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. Valkor 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. Valkor 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()`, `session.proxyUrl()`, or `session.runtime` — 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. ```ts import { SessionNotReadyError } from '@valkor/sdk'; const s = valkor.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](#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 | ```ts import { formatBillingErrorForUI } from '@valkor/sdk'; try { await valkor.session(projectId, sessionId).start(); } catch (err) { const ui = formatBillingErrorForUI(err); if (ui) showUpgradeModal(ui.alertTitle, ui.alertSubtitle); } ``` ### In `@valkor/sdk/react` `@valkor/sdk/react` re-exports `BillingError`, `RequestTooLargeError`, `parseBillingError`, `isBillingError`, and `formatBillingErrorForUI`. It does not re-export `ApiError` or `AuthError` — import those from `@valkor/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: ```ts interface ValkorSendError { kind: 'billing' | 'runtime-not-ready' | 'runtime-error'; message: string; billing?: BillingError; // set when kind is 'billing' cause: unknown; } ``` ```tsx const s = useSession(projectId, sessionId); if (s.sendError?.kind === 'billing') { const ui = formatBillingErrorForUI(s.sendError.billing); } ``` See [React hooks](/docs/sdk/react) for the rest of `useSession`. --- # Sign in with Valkor Gate your own app behind Valkor identity with one route, and act as the signed-in user through the SDK. Canonical page: https://beeuni.ai/docs/sdk/sign-in "Sign in with Valkor" makes Valkor the identity provider for an app you run: a dashboard, an internal tool, a vertical product built on Valkor. Your users sign in with their Valkor account, your server knows who they are, and every Valkor call your app makes runs as that user with that user's role assignments. The whole flow lives in `@valkor/sdk`. Your app never stores a Valkor token in the browser and never talks to Supabase. It is standard OAuth 2.1 (authorization code + PKCE) served by the Valkor API, so it works the same against `api.valkor.com` and against a self-hosted instance. > **Note** > Building an App **hosted by Valkor** (`*.apps.valkor.com`)? You need none of > this. The Apps gate already authenticated the viewer — read them with > `valkorAppViewerToken()` / `readAppViewer()`. See > [Apps → Your App already knows who is looking](/docs/sdk/apps). ## 1. Register your app Go to **Account → Tokens → OAuth apps → Register app**, or call the SDK: ```ts const app = await valkor.iam.oauthClients.create(accountId, { name: 'Dashboards', client_type: 'confidential', // 'public' for a browser/native app (PKCE only, no secret) redirect_uris: ['https://dashboards.example.com/api/valkor/auth/callback'], scopes: ['profile', 'email', 'valkor'], }); // app.client_id, app.client_secret (shown once) ``` Registration needs `token.create` on the account. Redirect URIs are compared byte for byte; `https` is required except on `localhost`. | Scope | Grants the app | |---|---| | `profile` | The user's id, email and account memberships (`GET /v1/accounts/me`). | | `email` | The email address (an alias for OIDC-shaped clients). | | `valkor` | Acting as the user on the whole Valkor API — projects, sessions, files, IAM probes. Without it the token is identity-only. | ## 2. Mount the handler ```ts // lib/valkor-auth.ts import { createValkorAuth } from '@valkor/sdk/server'; export const auth = createValkorAuth({ backendUrl: 'https://api.valkor.com/v1', clientId: process.env.VALKOR_OAUTH_CLIENT_ID!, clientSecret: process.env.VALKOR_OAUTH_CLIENT_SECRET, // omit for a public client redirectUri: 'https://dashboards.example.com/api/valkor/auth/callback', cookieSecret: process.env.VALKOR_AUTH_COOKIE_SECRET!, // ≥ 32 chars; encrypts the session cookie }); ``` ```ts // app/api/valkor/auth/[...valkor]/route.ts (Next.js App Router) import { auth } from '@/lib/valkor-auth'; const handle = (request: Request) => auth.handler(request); export { handle as GET, handle as POST }; ``` The handler serves every route under `basePath` (derived from the redirect URI — `/api/valkor/auth` above): | Path | Does | |---|---| | `/signin?return_to=/path` | Starts sign-in (PKCE S256 + state in a 10-minute cookie) and redirects to Valkor. | | `/callback` | Exchanges the code, sets the encrypted `HttpOnly` session cookie, redirects to `return_to`. | | `/refresh?return_to=` | Rotates the token pair and redirects. Used by `requireViewer`. | | `/signout?return_to=` | Revokes the refresh token at Valkor and clears the cookie. | | `/me` | The viewer as JSON, or `401`. Refreshes inline when the access token expired. | | `/proxy/*` | Forwards to the Valkor API as the viewer. The browser SDK's `backendUrl`. | `return_to` is always confined to a same-origin path. ## 3. Gate pages and act as the user ```ts // middleware.ts — every page needs a viewer import { auth } from '@/lib/valkor-auth'; export async function middleware(request: Request) { const gate = await auth.requireViewer(request); if (gate.response) return gate.response; // 302 → /refresh or /signin } export const config = { matcher: ['/((?!api/valkor/auth|_next).*)'] }; ``` ```ts // a server component / route handler const viewer = await auth.viewer(request); // { userId, email, accounts, scopes, token, expiresAt } | null const valkor = await auth.valkor(request); // request-scoped client acting as the viewer const projects = await valkor.projects.list(); const allowed = await valkor.iam.can(accountId, viewer!.userId, { action: 'project.write', resourceType: 'project', resourceId }); ``` `viewer()` is read-only and never consumes the single-use refresh token; use `requireViewer()` in middleware so a page never renders signed-out for a user whose refresh token is still good. ## 4. The browser ```tsx import { createValkor } from '@valkor/sdk'; import { SignInWithValkor, useValkorViewer } from '@valkor/sdk/react'; const valkor = createValkor(auth.clientConfig()); // backendUrl = '/api/valkor/auth/proxy' function Header() { const { status, viewer } = useValkorViewer(); if (status === 'signed-in') return {viewer.email}; return ; } ``` The browser client sends a sentinel bearer; `/proxy` swaps it for the viewer's real token on the server. `useSession`, `valkor.project(id).sessions.*` and every other SDK call work unchanged through it. ## What the user sees The first time, Valkor shows a consent screen naming your app and the scopes. Valkor remembers the decision per user and app, so later sign-ins redirect straight back. Revoking an app deletes every token it minted. ## Discovery `GET https://api.valkor.com/.well-known/oauth-authorization-server` (also under `/v1/oauth/.well-known/…`) publishes the endpoints for a generic OAuth client. The SDK does not need it — it derives every endpoint from `backendUrl`. --- # Change requests How session work reaches the default branch through review. Canonical page: https://beeuni.ai/docs/work/change-requests A change request (CR) merges one git branch into another. Valkor creates a CR from a session's branch (`head_ref`) onto the project's default branch (`base_ref`, usually `main`). The CR row is metadata; the merge, diff, and conflict checks run as real git operations against the project's repository. A CR is the only way session work reaches the default branch. ## Why work goes through a CR A session runs in a sandbox on its own branch, named after the session ID. The sandbox does not last forever, but the branch does: git is the only durable record of a session's work. Every new session starts from the default branch. Until a CR merges, the work stays on its own branch, unreviewed and invisible to every other agent, trigger, and collaborator. This applies to every change: code, agents, skills, and the manifest (`valkor.yaml`) — no exceptions. ## The agent mandate An agent must open a CR to land any change on the project's default branch. The agent does not merge its own CR — merging is the user's decision. Follow this contract: 1. Commit on the session branch (`$VALKOR_BRANCH_NAME`). Make small, working commits. Do not rewrite history or force-push. 2. Push the branch: `git push origin HEAD`. 3. Open the CR: `valkor cr open --title "..." --description "..."`. Inside a session sandbox, `--head` and `--session` default to `$VALKOR_BRANCH_NAME` and `$VALKOR_SESSION_ID`. `--base` defaults to the project's default branch. 4. Tell the user the CR number, so they can review it. 5. Stop. Do not merge the CR yourself. ### Anti-patterns - **Force-pushing to the default branch.** This breaks the review contract, even where the backend allows it. - **"It's on my branch, pull it yourself."** The session branch is gone once the sandbox stops, unless a CR merged it first. - **Sending the change as a file, paste, or archive.** The CR system already solves this problem. ## Data model CRs live in the `change_requests` table. | Column | Type | Notes | | --- | --- | --- | | `cr_id` | uuid | Primary key. The REST API's identifier. | | `project_id` | uuid | The project the CR belongs to. | | `number` | integer | Per-project display number (`#1`, `#2`…). Unique per project. Never recycles. | | `title` | text | Required. | | `description` | text | Defaults to an empty string. | | `base_ref` | text | The branch merged into. Usually `main`. | | `head_ref` | text | The branch merged from. In a session, this is the session ID (a UUID). | | `status` | enum | `open`, `merged`, or `closed`. | | `head_commit_sha` | text | Refreshed against the live `head_ref` tip on every read, for open CRs. Captured at merge time for merged CRs. | | `base_commit_sha` | text | Same rule, for `base_ref`. | | `origin_session_id` | text | The session that opened the CR. Set to null if that session is deleted. | | `created_by` | uuid | The user who created the CR. | | `merged_at` / `merged_by` | timestamp / uuid | When and who merged the CR. | | `merge_commit_sha` | text | The merge commit. Equals `head_commit_sha` for a fast-forward. | | `closed_at` / `closed_by` | timestamp / uuid | When and who closed the CR without merging. | | `metadata` | jsonb | Holds `requested_changes`, a list of `{text, by, at}` entries added by `POST /:crId/request-changes`. CRs have no separate comment table. | | `created_at` / `updated_at` | timestamp | Set on creation. Updated on every status change or SHA refresh. | A unique index on `(project_id, number)` lets you reference a CR by its short number instead of its UUID. ## Lifecycle ``` open ──(merge)──▶ merged (terminal) open ──(close)──▶ closed ──(reopen)──▶ open ``` - `open` is the starting status. - `closed` is reversible. `POST /:crId/reopen` sets it back to `open`. - `merged` is terminal. You cannot reopen or close a merged CR. Open a new CR against the merged state instead. Valkor refuses to create a CR whose branch has no commits ahead of the default branch. This usually means the agent committed locally but never pushed. Push the commits, then create the CR again. ## SHA refresh For an open CR, Valkor refreshes `head_commit_sha` and `base_commit_sha` against the live branches on every `GET`. If the repository is unreachable, or a branch is missing, Valkor skips the refresh and serves the CR's last known metadata. A merged CR keeps the SHAs captured at merge time — Valkor never refreshes them again. ## Merge mechanics `POST /v1/projects/:projectId/change-requests/:crId/merge` runs these steps. 1. Valkor reads the manifest (`valkor.yaml`) from `head_ref` and validates it against the manifest schema. A branch with no manifest passes. An invalid manifest returns `422` with `code: "MANIFEST_INVALID"` and stops the merge. 2. Valkor fast-forwards `base_ref` if `head_ref` is strictly ahead of it. 3. Otherwise, Valkor creates a merge commit. The default message is `Merge CR #: `, and you can override it with `message` in the request body. The commit author is `Valkor <noreply@valkorai.com>`. 4. A conflict returns `409` with the conflict list. Check the same list with `GET /:crId/merge-preview` before you merge. 5. On success, Valkor sets `status` to `merged`, records `merged_at`, `merged_by`, and `merge_commit_sha`, and invalidates the project's git cache. Merging a CR that is not `open` returns `409`. ### Merge preview `GET /:crId/merge-preview` returns: | Field | Type | Meaning | | --- | --- | --- | | `base_sha` | string | Current tip of `base_ref`. | | `head_sha` | string | Current tip of `head_ref`. | | `merge_base` | string \| null | Common ancestor. Null if the histories are unrelated. | | `is_up_to_date` | boolean | `head_ref` is fully merged into `base_ref`. | | `can_merge` | boolean | No conflicts. | | `can_fast_forward` | boolean | `head_ref` is strictly ahead of `base_ref`. | | `conflicts` | string[] | File paths that would conflict. | ## REST API All routes sit under `/v1/projects/:projectId/change-requests`. | Method | Path | Notes | | --- | --- | --- | | GET | `/` | `?status=open\|merged\|closed\|all`. No filter returns every status. | | POST | `/` | Body: `{title, description?, head_ref, base_ref?, session_id?}`. Returns `201`. | | GET | `/:crId` | Returns the CR. Refreshes SHAs as a side effect. | | PATCH | `/:crId` | Edits `title` or `description`. `409` if not `open`. | | GET | `/:crId/diff` | Unified patch: file list, additions, deletions. | | GET | `/:crId/merge-preview` | See Merge preview above. | | POST | `/:crId/merge` | Body: `{message?}`. `422` on an invalid manifest. `409` on conflict or if not `open`. | | POST | `/:crId/close` | `409` if already `merged`. | | POST | `/:crId/reopen` | `409` if not `closed`. | | POST | `/:crId/request-changes` | Body: `{feedback}`. Appends to `metadata.requested_changes` and wakes the originating session's agent. `409` if not `open`. | `POST /` rejects a `head_ref` with no commits ahead of `base_ref`: `422 code: "CR_HEAD_NOT_AHEAD"`. This is the error an agent sees if it opens a CR before pushing its branch. ### Authorization Read routes need read access to the project. Write routes need write access. Each write action also needs a capability, shown below for a full token and for a session's scoped token. | Action | Full-token capability | Scoped-token capability | | --- | --- | --- | | Open a CR | `project.gitops.push` | `project.cr.open` | | Request changes | `project.review.act` | `project.cr.open` | | Merge | `project.gitops.merge` | `project.cr.merge` | These are separate capabilities, so a token can open CRs without the power to merge them. This is the mechanism behind the agent mandate above. --- <!-- /markdown/docs/work.md --> # Running work How work moves from prompt to merged change, and the three ways it starts. Canonical page: https://beeuni.ai/docs/work Valkor does work inside a [session](/docs/work/sessions): a branch and a sandbox for one unit of work. A session ends when its [change request](/docs/work/change-requests) (CR) merges back to the default branch. This page walks through the loop, then shows the three ways a session can start. - [Sessions](/docs/work/sessions): A branch and a sandbox for one unit of work. - [Change requests](/docs/work/change-requests): The reviewed merge back to the default branch. - [Runtime](/docs/work/runtime): Env vars, tokens, and the sandbox image a session runs in. ## What happens when a session starts 1. Valkor creates the session row and cuts a branch from the default branch. The branch name is the session id. 2. Valkor resolves a sandbox image: the default image, or your own `.valkor/Dockerfile` if the manifest declares one. 3. The sandbox boots. Its daemon, `valkor-agent`, clones the repo to `/workspace` and starts OpenCode. Session status becomes `running`. 4. The agent works. It reads [secrets](/docs/project/secrets) as environment variables, then commits and pushes to the session branch. 5. The agent opens a [change request](/docs/work/change-requests). You review it and merge it — the only way work reaches the default branch. > **Git is the only durable record** > Stopping a session pauses the sandbox but keeps its files. Deleting a session > destroys the sandbox for good. Only work committed and pushed to the branch > survives, and only a merged change request makes it permanent. ## Three ways work runs A session starts one of three ways. | Mode | How it works | |---|---| | On-demand | You ask in chat and get the result now. | | Human-assisted | The agent works and checks in with you for the calls that matter. | | Automated | A [trigger](/docs/connect/triggers) — a schedule or webhook — starts the session end to end. | ## Related - [Projects](/docs/project): A git repo with a manifest. - [Agents](/docs/project/agents): A markdown persona with scoped tools. - [Models](/docs/project/models): Which model a session uses, and who pays. - [Triggers](/docs/connect/triggers): Schedules and webhooks that start sessions. - [Connectors](/docs/connect/connectors): Scoped reach into external apps. - [Slack & channels](/docs/connect/slack): Chat surfaces that start sessions. - [Computers](/docs/connect/computers): Machines distinct from session sandboxes. - [Accounts](/docs/accounts): Principals, roles, and assignments — who can do what. --- <!-- /markdown/docs/work/runtime.md --> # Runtime & sandbox Sandbox lifecycle, injected environment, token families, and the image a session boots from. Canonical page: https://beeuni.ai/docs/work/runtime A session runs in an isolated sandbox on Daytona, Platinum, or E2B Cloud, built from a layered image. This page is the reference for the sandbox lifecycle, the environment Valkor injects at boot, the token families, and the sandbox image itself. For the session concept, see [Sessions](/docs/work/sessions). ## Sandbox lifecycle A session row carries a `status`. The enum defines `queued`, `branching`, `provisioning`, `running`, `stopped`, `failed`, `completed`, but Valkor only writes 4 of them. | Status | Set when | | --- | --- | | `provisioning` | At session create. Valkor creates the session branch and requests the sandbox. | | `running` | Once the sandbox is live and reachable. | | `stopped` | On explicit stop, or by the idle sweep that hibernates inactive sandboxes. | | `failed` | If provisioning fails. | `queued`, `branching`, and `completed` exist in the enum but stay dead in the session flow. Do not treat them as live states. The sandbox itself carries a separate `status` in its own row, with its own enum. | Status | Set when | | --- | --- | | `provisioning` | The provider boots the sandbox. | | `active` | The provider confirms the sandbox is live. | | `stopped` | Explicit stop, idle auto-stop, or mid-restart. | | `error` | The provider reports a boot or runtime failure. | | `archived` | Terminal. You deleted the session and the provider destroyed the sandbox, not paused it. | Valkor enforces a concurrent-session limit per account. Exceeding your tier's limit returns `429`. ### Active-turn protection While an OpenCode turn is `busy` or `retrying`, the sandbox daemon renews a short execution lease with the API every 60 seconds. The lease blocks the idle reaper. Each renewal also touches the provider, so the provider's own inactivity timer cannot hibernate the sandbox mid-run. `session.idle` and `session.error` release the lease. An open dashboard tab, preview, SSE connection, or health poll does not create a lease. A passive tab cannot keep an idle sandbox alive. ### Branch model - The session branch is named after the session id (a UUID). `VALKOR_SESSION_ID` and `VALKOR_BRANCH_NAME` carry the same value. - Valkor cuts the branch from `base_ref`, which defaults to the project's default branch, at session-create time. - Triggers create their session branch the same way an interactive session does. - Nothing writes the default branch directly. Only a merged [change request](/docs/work/change-requests) does. ### Reconcile a session branch Use **Ask Agent: Sync Branch & Reload** from the session command palette when the base branch changed. The agent inspects the session branch, preserves local work, fetches the latest `base_ref`, resolves conflicts, runs the relevant tests, and commits the reconciliation. Valkor does not choose one side of a conflict or reset the working tree. The agent finishes with `valkor sessions reload "$VALKOR_SESSION_ID" --project "$VALKOR_PROJECT_ID" --no-repo --force --yes`. `--no-repo` is required because the agent already reconciled the branch. The reload replaces the OpenCode runtime after the replacement becomes healthy. It ends the current turn, so send `continue` after the runtime returns. The session header reports each server-confirmed reload phase in real time. A web reload checks the session, compiles the agent config, applies and validates the runtime replacement, then confirms the active config. It does not refresh the repository. The CLI can refresh the repository unless you pass `--no-repo`. ## Layout inside the sandbox ``` /workspace ← WORKDIR. The project repo is cloned here. /workspace/.valkor/ ← Repo-internal Valkor folder (Dockerfile + opencode config dir). /usr/local/bin/valkor-agent ← The daemon (supervisor + reverse proxy). /usr/local/bin/valkor-entrypoint ← The container ENTRYPOINT (PID 1). /opt/valkor/home ← OpenCode's HOME — its object store lives here, off the repo. ``` OpenCode's `HOME` is `/opt/valkor/home`, not `/workspace`. Its object store never lands among your repo files. ## Injected environment Valkor injects these variables at boot. Only a project secret explicitly configured for `runtime` delivery enters the sandbox. Connector and model-provider credentials stay on the Valkor server. | Variable | What | | --- | --- | | `VALKOR_PROJECT_ID` | UUID of this project. | | `VALKOR_SESSION_ID` | UUID of this session. Also the branch name. | | `VALKOR_BRANCH_NAME` | Same value as `VALKOR_SESSION_ID`. | | `VALKOR_REPO_URL` | Clone URL for the project repo. | | `VALKOR_DEFAULT_BRANCH` | The project's default branch. | | `VALKOR_BASE_REF` | The ref this session branched from. | | `VALKOR_SERVICE_PORT` | `8000` — the daemon's external port. | | `VALKOR_API_URL` | The platform API base (`.../v1`). | | `VALKOR_AGENT_NAME` | The agent the session was created with. | | `VALKOR_OPENCODE_MODEL` | The model to run, when set. | | `VALKOR_PROJECT_AUTO_CLONE` | `1` — tells the daemon to clone the repo on boot. | | `VALKOR_PROJECT_SECRET_NAMES` | Comma-separated names of the project's secrets. | | `VALKOR_PROJECT_SECRETS_REVISION` | Revision marker for the secret set. | | `VALKOR_BOOTSTRAP_OPENCODE_SESSION` | `1` — always set. Tells the daemon to create the OpenCode root on cold boot. | | `VALKOR_LLM_BASE_URL` | Valkor LLM-gateway base URL. The gateway resolves provider credentials server-side. | | `VALKOR_TOKEN` | The session-bound Valkor credential. See below. | Valkor does not inject `VALKOR_WORKSPACE`. The image bakes in `/workspace` and no per-session step sets it. Valkor does not inject a git token either — the daemon fetches a short-lived clone credential when it needs one; see [Pushing from a session](#pushing-from-a-session) below. Valkor rejects a user secret named with the `VALKOR_*` prefix, because it reserves that prefix for platform variables. ## Session credential Valkor has external tokens you create yourself and one credential inside each sandbox. ### External tokens A personal access token (PAT, prefixed `valkor_pat_`) or a service account (prefixed `valkor_sa_`) authenticates calls to the Valkor API, the SDK, and the CLI from outside a sandbox. See [Authentication](/docs/sdk/auth) for PAT scope, service-account setup, and how to choose between them. ### In-sandbox token `VALKOR_TOKEN` is minted when Valkor starts the session environment. It is bound to the launching user, project, session, and agent grant. The daemon, CLI, Git credential helper, LLM gateway, and connector gateway use this same credential. Each API route still applies its own capability check. The initial prompt and turn-ledger identifiers are not environment variables. The daemon claims them from the API with `VALKOR_TOKEN` after boot. ## Pushing from a session The daemon sends `VALKOR_TOKEN` only to the Valkor Git proxy. The proxy resolves the upstream Git credential on the server. No upstream Git token enters the sandbox. `git push origin HEAD` sends commits to the session branch. Landing on the default branch requires a merged [change request](/docs/work/change-requests). ## The agent runtime The daemon launches OpenCode as `opencode serve --port 4096 --hostname 127.0.0.1`, with `OPENCODE_CONFIG_DIR` set to the project's config directory (default `.valkor/opencode`) inside the cloned repo. See [Agents](/docs/project/agents) for how a session picks an agent and its config. ## Transcript attachments and memory OpenCode stores tool screenshots as base64 `data:` URLs inside its SQLite transcript. The daemon keeps that store small and the box alive: - **Attachment offload.** While no turn runs (every 5 minutes, at boot, and after a memory-guard event), attachment bytes older than the newest 12 per session — and every tool result OpenCode's compaction already cleared — move to `~/.local/share/valkor/attachments/<id>`. The row keeps a 1×1 PNG placeholder plus a `valkor.offloaded` marker; `/valkor/part` serves the real bytes to the UI. Models never receive those old images anyway (the LLM proxy keeps the newest 12 per request). Set `VALKOR_ATTACHMENT_OFFLOAD=0` to disable. - **Resource telemetry.** `[resources]` in the daemon log every 60 s and on every OpenCode state change: box memory, cgroup limit, load, disk, daemon and OpenCode RSS. - **Memory guard.** Above 80 % memory the daemon samples every 10 s; at 92 % (`VALKOR_MEMORY_GUARD_PCT`) with a turn in flight it aborts the turn cleanly and reports `SandboxMemoryGuard` with the numbers as the turn's error, instead of letting the kernel kill OpenCode mid-turn. ## The daemon control surface The `valkor-agent` binary runs as PID 1's child and fronts OpenCode on `VALKOR_SERVICE_PORT` (`8000`). Every route outside `/valkor/*` requires the HMAC-signed `X-Valkor-User-Context` header, validated against `VALKOR_TOKEN`. | Path | Purpose | | --- | --- | | `GET /valkor/health` | Liveness check (no auth required). Reports daemon and OpenCode state, repo, branch, commit. | | `POST /valkor/refresh` | Re-pull the session branch and restart OpenCode in place. | | `POST /valkor/abort` | Abort the current run. | | `POST /valkor/env` | Update the runtime environment. | | `/valkor/pty` | Backs the in-dashboard terminal. | | `GET /valkor/logs` | Tails the daemon's own log file (`/opt/valkor/logs/daemon.log`, rotated at 32 MiB) or OpenCode's. `?source=daemon\|opencode\|all`, `?tail=N` (default 500, max 5000). Plain text. Same auth as `/valkor/refresh`. | | `GET /valkor/part/:sessionID/:messageID/:partID` | Attachment bytes on demand — a top-level file part or a tool result's `state.attachments[]` entry. Bytes the daemon offloaded to a sidecar file are served from there. | | `GET /valkor/diag` | One JSON error report: OpenCode state/pid/port/port pair, boot timeline, a fresh resource snapshot (memory, cgroup limit, load, disk, RSS, duplicate opencode processes), the runtime-assets report, and the tail of both logs (`?tail=N`, default 200). Same auth as `/valkor/logs`. | | `/proxy/{port}/*` | Reverse-proxy to another port inside the sandbox. The daemon's own port is blocked. | | `*` | Catch-all reverse-proxy to OpenCode on `127.0.0.1:4096`. Returns `503` while OpenCode boots. | Run `/valkor/refresh` to apply an out-of-band change, such as a manifest edit committed from a parallel session, without re-provisioning the sandbox. ## The sandbox image Every sandbox boots from an image built in two layers. Your Dockerfile defines the base environment. The Valkor runtime layer is added on top, so the dashboard can connect to the sandbox. ``` ┌─────────────────────────────────────────┐ │ Valkor runtime layer (added on top) │ ← opencode + valkor-agent + entrypoint ├─────────────────────────────────────────┤ │ Your Dockerfile │ ← .valkor/Dockerfile └─────────────────────────────────────────┘ ``` If your project has no Dockerfile, Valkor builds sessions from a bare `ubuntu:24.04` image plus the runtime layer below. ### Declare a template Reference your Dockerfile as a named template under `sandbox.templates` in `valkor.yaml`. ```yaml sandbox: templates: - slug: dev name: Dev box dockerfile: .valkor/Dockerfile default: dev ``` Set exactly one of `dockerfile` or `image` on each entry. Dockerfile paths must stay inside the repository. Set `default` to the template slug your sessions should use; omit it to use the platform default image. Older projects on `valkor.toml` follow the same fields — see [legacy `valkor.toml`](/docs/project/legacy-toml). Full field list: [manifest reference](/docs/project/manifest). ### What the runtime layer adds Valkor appends this layer on top of your Dockerfile's final stage. - A system package floor with `git`, `curl`, `build-essential`, `ffmpeg`, and `tmux`. - pnpm-managed Node.js and npm, plus uv-managed Python 3. - A document-tools floor: LibreOffice, Pandoc, and OCR tools. - An exact uv-managed Python version exposed as `python` and `python3`. Use `uv run --with <package>` for third-party dependencies. - `opencode-ai`, the `bun` runtime, and `agent-browser` with a baked Chromium build. - The `valkor-agent` daemon, the `valkor` CLI, and the entrypoint script. - `ENV VALKOR_WORKSPACE=/workspace`, `WORKDIR /workspace`, `EXPOSE 8000`, and the entrypoint that starts the daemon. Everything you install in your own Dockerfile stays on `PATH`. Valkor does not remove or relocate it. ### Constraints | Rule | Why | | --- | --- | | Don't set `ENTRYPOINT` or `CMD`. | Valkor overrides both to start the daemon. | | Don't claim port `8000`. | Reserved for the daemon's reverse proxy. Run dev servers on other ports. | | `FROM` a Debian or Ubuntu base. | The runtime layer runs `apt-get`. Alpine, Fedora, and Arch fail the build. | | Don't run `apt-get clean` without `rm -rf /var/lib/apt/lists/*`. | The runtime layer re-runs `apt-get update`; a broken cache breaks the build. | | Don't bake credentials into the image. | Declare the name in `env:` and set the value as a project secret. Valkor injects it at session start. | ### Hardware spec `cpu`, `memory`, and `disk` on a template entry set the sandbox size. All three are optional; an omitted field uses the platform default: 2 vCPU, 4 GiB memory, 20 GiB disk. ```yaml sandbox: templates: - slug: big image: ubuntu:24.04 cpu: 4 memory: 8 disk: 50 ``` `cpu` takes 1–32 cores, `memory` takes 1–128 GiB, and `disk` takes 1–500 GiB. Valkor clamps any value above these limits down to the limit. Valkor does not support GPUs; a `gpu` key on a template produces a warning, not an error. The spec is part of the template's snapshot, not a per-session setting. Changing it rebuilds the snapshot and applies to the next session. The current session keeps its already-booted spec. The default spec costs about $0.10 per hour on Daytona, the default sandbox provider. The same spec on Platinum or E2B costs about twice as much, because the Daytona rate includes a volume discount the other providers don't. Valkor meters this cost only for Team accounts; free and self-hosted plans aren't billed for it. ### Ports and preview URLs The daemon listens on port `8000` and proxies any other port your app uses inside the sandbox. Only port `8000` itself is blocked from the proxy. The dashboard reaches a sandbox port two ways: - **Path-based**: `https://<api-host>/v1/p/<sandbox-id>/<port>/...`. This is the default form and the only one that supports WebSocket upgrades. - **Subdomain-based**: `https://p<port>-<sandbox-id>.<api-host>/...`. Use this form for apps that need root-relative paths or cookies, such as a Next.js or Vite dev server. WebSocket upgrades don't work on this form yet. The preview proxy strips `X-Frame-Options` and any `frame-ancestors` policy. A session can embed your app's preview in an iframe without any config on your side. A session's preview can also be shared through a public link, in view-only or interactive mode. Public share links block ports `22`, `4096`, `8000`, and the static file-share port. ### Snapshot rebuilds Valkor content-addresses each snapshot: it hashes your Dockerfile's bytes, the hardware spec, and the platform's own runtime version. An unchanged hash reuses the existing snapshot. A changed hash triggers a rebuild, shown as "preparing image" on the first session that needs it; later sessions reuse that build. Editing the Dockerfile inside a session takes effect on the next session, not the current one. The edit reaches `main` only once its [change request](/docs/work/change-requests) merges. --- <!-- /markdown/docs/work/sessions.md --> # Sessions A session is a git branch and a sandbox where the agent works. Canonical page: https://beeuni.ai/docs/work/sessions A session is one unit of agent work. Valkor cuts a git branch and provisions a sandbox for it. The session id, the branch name, and the sandbox id are the same value. ## Status A session reaches one of 4 states in practice. | Status | Meaning | |---|---| | `provisioning` | Valkor cuts the branch and requests the sandbox. | | `running` | The sandbox is live and reachable. | | `stopped` | The session is paused, by you or by idle auto-stop. | | `failed` | Provisioning failed. | The database defines 3 more values (`queued`, `branching`, `completed`). Valkor does not write them for a session today. ## Stop, resume, and idle auto-stop You can stop a session yourself. Resume brings back the same sandbox with the same filesystem and runtime identity. Only the running processes and memory reset. Valkor also stops an idle session for you: - After 15 minutes idle, for a normal session. - After 5 minutes idle, for a session a trigger started. An open dashboard tab does not keep a session alive. A busy agent turn blocks the stop. The maintenance sweep runs every 5 minutes. A normal automatic Stop therefore occurs approximately 15 to 20 minutes after the terminal turn. Self-host operators can set `VALKOR_SANDBOX_AUTOSTOP_MINUTES` to change the normal idle grace. This setting does not change active-turn protection. ## What stop and delete keep > **Deletion is permanent** > Deleting a session destroys its sandbox for good. Valkor keeps the session > record and the git branch, so you can still recover pushed work. Anything not > pushed is gone. Stop and resume keep the sandbox's identity and filesystem. Delete destroys the sandbox. Git is the only durable record: work the agent commits and pushes survives; everything else does not. ## Sharing a preview You can share a session's live preview with a public link, in view-only or interactive mode. Minting that link is the session owner's call, for the same reason: the link is unauthenticated, so anyone holding the URL reads the session without signing in. A project manager can list and revoke a session's links without owning it — revoking only ever removes access. ## Providers Valkor runs sessions on Daytona, Platinum, or E2B Cloud. A project follows the platform default, or requests a provider switch through the SDK — see [SDK reference](/docs/sdk/reference). A switch to a different provider is durable: the current provider keeps serving while the target warms, then activates. Every provider runs the same sandbox image. For the full status enum, injected environment variables, and daemon endpoints, see [Runtime](/docs/work/runtime). For how a session picks its agent, see [Agents](/docs/project/agents). For sessions a schedule or webhook starts, see [Triggers](/docs/connect/triggers). To land session work on the default branch, see [Change requests](/docs/work/change-requests).