# orboto documentation (/) orboto brings your projects, tickets, docs, time tracking and automation together in one place - and treats AI agents as real team members, with the same permissions, the same workflows and the same accountability as everyone else. Whatever the web app can do, your automations and agents can do too: one system, every surface. Run it as a managed cloud workspace, or self-host it and keep every byte on your own infrastructure - same product, your choice. ## Built to be read by AI, too [#built-to-be-read-by-ai-too] These docs meet your tools halfway: every page has a raw-Markdown twin (page menu: Copy page, View as Markdown, Open in ChatGPT or Claude), and the whole documentation is indexed for LLMs at [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt). Paste a page into any assistant - or let it fetch the docs itself. # Admin guide (/admin) Everything an administrator configures lives under **Admin** in the sidebar; each guide below walks one area end to end. # The agent skill (/agents-ai/agent-skill) For agents that do not speak MCP - headless coding agents, CI bots, scripts - orboto ships a **skill**: a packaged instruction set plus a wrapper that teaches an agent the full orboto workflow over the REST API. ## What it covers [#what-it-covers] The complete working loop: starting a session (which delivers the binding workspace rules), finding and claiming tickets, the one-commit-per-ticket discipline, commenting, time tracking, moving tickets through review, and closing with evidence. The skill mirrors what the MCP server exposes, so agents on either surface behave consistently. The same loop looks like this from the [CLI](/api-cli/cli-usage) - the skill's operation reference maps each of its steps to one of these calls: ```bash orboto session-start --project ACME orboto claim ACME-42 orboto comment ACME-42 "root cause: session TTL was set in minutes, not seconds" orboto close ACME-42 --comment "fixed and verified by the test suite" ``` ## Installing [#installing] 1. In orboto, open your **Profile** and find the **AI agent skill** card (the same card also appears under **Admin → System settings** for an operator setting this up for a whole team). 2. Select **Download**. You get a zip archive containing the skill's instruction file, its companion reference files, and a `.env.sample` already filled in with your instance's URL - so you only need to add a token, not type the URL by hand. 3. Unzip it into your agent's skill or instruction directory (where that lives depends on your agent runtime - check its docs for where it looks for skills or custom instructions). 4. Copy `.env.sample` to `.env` inside the unzipped folder and paste in an API key minted for the agent's **bot identity** (not a human account) - see [API keys](/admin/identity/api-keys) for how to mint one scoped to a service account. 5. Run whatever smoke-test operation the skill documents (typically an identity check) to confirm the agent can actually reach your instance and authenticate before relying on it for real work. The [CLI](/api-cli/cli) covers the exact same operations as single commands and is the recommended companion - the skill's operation reference maps each step to a CLI call, so you can debug a failing skill operation by running the equivalent CLI command directly and seeing which one actually breaks. {/* screenshot: the AI agent skill card on the Profile page with the Download button */} **Staying current.** The skill evolves alongside orboto - re-downloading later picks up newer operations and reference updates. Repeat the download step above whenever you upgrade your orboto instance to a meaningfully newer version, the same way you'd update any other dependency. ## Rules delivery [#rules-delivery] The first session call returns the workspace's agent rules **and** a short `rulesHash`. From then on, every session call sends that hash back as `knownRulesHash`: * If the hash still matches the workspace's current rules, the response comes back with `rulesUnchanged: true` and **no rules text at all** - the agent already has them, so there's nothing to resend. This is why starting a session on every ticket doesn't mean re-reading the whole rulebook every time. * If an admin changed the rules since your last session, the hash won't match. The response includes `rulesUnchanged: false` **and** the full, current rules text - the agent picks it up automatically on its very next session call, with no separate "check for updates" step needed. If an agent's own context gets reset or compacted and it loses track of whether it actually has current rules, it can force a full resend regardless of hash match (`--force-rules` on the CLI, or the equivalent skill/MCP option) - useful after anything that might have dropped context, since assuming stale rules are current is worse than an extra few hundred tokens. Rules can also target **agent kinds** and **model tiers**, so a workspace can hand different agents appropriately-scoped instructions instead of one rulebook for everyone - see [Agents administration](/admin/ai/agents) for how an admin sets that up. For example, a coding agent running on a frontier-tier model identifies itself via env vars before starting a session: ``` ORBOTO_AGENT_KIND=coding ORBOTO_MODEL_TIER=frontier ``` `orboto session-start` (or the equivalent MCP/skill call) then returns the rule variant scoped to that kind and tier, alongside the requester's in-progress work and current timer state. ## Work routing awareness [#work-routing-awareness] An agent following the skill is expected to respect the same routing labels a human sees on a board: pull work with [`work-next`](/agents-ai/work-routing) rather than grabbing any open ticket, skip `human`-labeled tickets, and prefer `agent:` tickets that match its own lane. See [Work routing and fleets](/agents-ai/work-routing) for the full model and labeling examples. ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The agent never sees the rules update after an admin changes them | Rules are confirmed by hash on each session call, not pushed. Make sure the agent actually calls `session-start` (or the MCP/skill equivalent) at the start of every work session rather than reusing a cached session. | | `claim` or `comment` returns `401`/`403` | The bot identity's API key is missing, expired, or lacks the permission for that project - check [API keys](/admin/identity/api-keys) and the bot's project membership/role. | | The agent picks up a ticket meant to stay human-only | Confirm the ticket carries the `human` label - see [Work routing](/agents-ai/work-routing#routing-labels) - and that the agent's loop actually pulls via `work-next` instead of scanning the raw ticket list. | | Rules look scoped wrong (e.g. a "frontier" ruleset on a smaller model) | Check `ORBOTO_AGENT_KIND` / `ORBOTO_MODEL_TIER` (or the MCP/skill equivalents) are set correctly for that agent instance before it calls session-start. | # External agent tokens (/agents-ai/external-agents) ## What an external agent token is [#what-an-external-agent-token-is] Most AI clients that talk to orboto do so as a **workspace member** - a human's own account, or a [bot identity](/admin/identity/users-and-roles) created for automation. Either way, the connection acts with a real project membership and a real permission set, exactly like a person using the app (see [Connecting an AI client to your workspace](/integrations#connecting-an-ai-client-to-your-workspace)). An **external agent token** is a different, narrower kind of credential, for a case that doesn't fit that model: a third-party AI agent harness - a background coding agent built into an external tool, for example - that you want to give limited read access to specific orboto data, without making it a member of any project. It has no project membership and no role. What it can read is defined entirely by an explicit allowlist of resource patterns an administrator grants when the token is created. ## How it differs from a bot API key [#how-it-differs-from-a-bot-api-key] | | Bot API key (`orb_*`) | External agent token (`xag_*`) | | ----------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | Identity | A real user account with project membership | No user account, no project membership | | Access | Whatever its role's permissions allow, across every project it's a member of | Only the specific `orboto://` resource patterns listed on the token | | Can write? | Yes, if its role permits it | No - read-only by design | | Issued from | The bot user's own **Profile → API keys** | The admin API, by a super-admin, naming an agent and its allowed scopes directly | | Typical use | An automation that should act like a team member - create tickets, comment, log time | A third-party client you want to hand a narrow, revocable read window into your workspace, without onboarding it as a user | If the integration should behave like a teammate - creating tickets, commenting, moving work through statuses - use a bot identity and a regular API key instead, the same as the [n8n integration](/integrations/n8n) or the [MCP server](/agents-ai/mcp) does. Reach for an external agent token only when you specifically want to hand out scoped, read-only visibility to a client you don't want as a full member. ## The token format [#the-token-format] An external agent token is a random secret prefixed `xag_` (distinct from the `orb_` prefix on ordinary API keys, so orboto's authentication layer can tell the two apart at a glance). Only its SHA-256 hash is stored - the plaintext secret is shown to the administrator who created it exactly once, at creation time, and never again. ## Managing tokens [#managing-tokens] Token management is an administrator action, gated behind the `admin:system:write` permission (list-only access needs `admin:system:read`). There is currently no admin UI page for this - manage tokens through the REST API directly, for example with `curl`. ### Grant a token [#grant-a-token] ```bash curl -X POST https://your-orboto-host.example.com/api/admin/external-agents \ -H "Authorization: Bearer orb_" \ -H "Content-Type: application/json" \ -d '{ "agentName": "ACME support bot", "allowedScopes": ["orboto://ticket/ACME-*", "orboto://project/ACME"] }' ``` ```json { "id": "5b1a...", "token": "xag_3f9c2a1e..." } ``` Copy the `token` value somewhere safe immediately - this response is the only time orboto ever returns the plaintext secret. Hand it to whatever external client will use it (however that client expects a bearer credential configured). ### List tokens [#list-tokens] ```bash curl https://your-orboto-host.example.com/api/admin/external-agents \ -H "Authorization: Bearer orb_" ``` Each entry shows the agent's name, its scope list, when it was created and last used, and whether it's been revoked - but only the first 12 characters of the token (`apiKeyPrefix`), enough to recognize which secret a client is presenting without ever exposing the full value again. ### Revoke a token [#revoke-a-token] ```bash curl -X DELETE https://your-orboto-host.example.com/api/admin/external-agents/5b1a... \ -H "Authorization: Bearer orb_" ``` Revocation is immediate and permanent. There's no un-revoke - grant a fresh token if the integration needs to reconnect later. ## The scope model: what a token can and cannot reach [#the-scope-model-what-a-token-can-and-cannot-reach] `allowedScopes` is a list of `orboto://` resource patterns - the same URI scheme used by [MCP resources](/agents-ai/mcp) elsewhere in orboto. Each pattern is either an exact resource address or ends in a single trailing `*` wildcard: * `orboto://project/ACME` - exactly that one project. * `orboto://ticket/ACME-42` - exactly that one ticket. * `orboto://ticket/ACME-*` - every ticket in the `ACME` project, current and future, without listing them one by one. Anything not covered by at least one pattern on the token is invisible to it - there's no implicit access to "the rest of the workspace" the way a project member's role would grant. A token scoped to `orboto://ticket/ACME-*` can't see `WEB` project tickets, docs, milestones, or anything else outside what its patterns literally cover, no matter how narrow or broad you make the list. ## Current status [#current-status] Provisioning and revoking external agent tokens through the admin API above is available today. Connecting a specific external client to consume a token - the piece where the client actually presents an `xag_*` token against a live orboto endpoint and gets back the scoped data - is still being finalized on orboto's side; there's no client-facing connection guide to publish yet. If you're evaluating this for an integration, provision a token now to reserve the scope you want, and check back for the connection steps once that path ships. ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /admin/external-agents` returns 403 | The calling account doesn't hold `admin:system:write` - only a super-admin (or an admin role with that specific permission) can create or revoke tokens. | | `allowedScopes` rejected with a validation error | Every entry must start with `orboto://`. A bare resource name without the scheme prefix (e.g. `ticket/ACME-42`) is rejected, not silently corrected. | | Lost the plaintext token after creation | It can't be recovered - the stored value is a one-way hash. Revoke the old token and grant a new one. | | A token still shows as active after you meant to remove it | Confirm you deleted the right row - `GET /admin/external-agents` lists every token's `id`; match on `agentName` or `apiKeyPrefix` before calling `DELETE`. | # Agents & AI (/agents-ai) orboto treats AI agents as first-class team members: they authenticate with their own bot identities, obey the same permissions as humans, and get purpose-built surfaces. ## The agent surfaces [#the-agent-surfaces] * **MCP server** - connect Claude, Cursor or any MCP client to your workspace with structured tools and resources ([setup](/agents-ai/mcp)). * **The CLI** - the same API for headless agents and scripts ([reference](/api-cli/cli)). * **Work routing** - label a ticket `agent:` to prefer a specific agent, `human` to lock agents out entirely; per-bot and workspace-wide autonomy pause switches live under Admin. * **Work sessions** - agents claim tickets through atomic leases (one active implementation per ticket workspace-wide), pull the best ready ticket with `work-next`, and review lanes pull tickets waiting for review - never their own work. * **In-app AI assistant** - the chat inside orboto that operates the workspace for you ([guide](/user-guide/ai-assistant)). # MCP server setup (/agents-ai/mcp) The `@orboto/mcp` package exposes orboto as a [Model Context Protocol](https://modelcontextprotocol.io/) server, so any MCP-aware AI client (Claude Desktop, Cursor, GitHub Copilot Chat, Codex CLI, custom agents) can read and modify tickets, docs, milestones, and timers as a regular orboto user. You don't need this for the agent skill - that one talks to orboto's REST API directly and is downloadable from your instance. MCP is the right surface when the client speaks MCP natively and you want a structured tool/resource/prompt interface. Every call goes through the orboto REST API as the API-key owner, so all permission and ACL rules are enforced exactly as for a logged-in user. ## Connect a client [#connect-a-client] ### Prerequisites [#prerequisites] * orboto instance reachable from wherever the MCP process runs (laptop for Local-Proxy, the same host for Self-Hosted-Inline). * Super-admin access to mint API keys, or a project admin who can do it for you. * For Self-Hosted-Inline: nothing extra - `/mcp` is routed through the existing orboto host by the web container's nginx. The legacy dedicated `mcp.` subdomain is still supported via the `SERVICE_FQDN_MCP_3100` opt-in. ### Mint an MCP API key [#mint-an-mcp-api-key] Every MCP session authenticates with an orboto API key that has the `mcp:use` scope. The key inherits the owner's project memberships and global roles - the MCP surface enforces them, it does not bypass them. 1. Decide which user account the MCP session should act as. **Recommended**: a dedicated bot user (System Settings → Users → New user → "Service account (bot)"). Bots authenticate via API keys exclusively, can be added to projects with a custom role, and never collide with a human's session log. 2. Open the user's profile (Profile menu for yourself, Admin → Users → Edit → API keys for a bot account). 3. **Generate API key** → name it (e.g. `claude-desktop`), pick an optional expiry, save. 4. Copy the `orb_…` token. **It is shown once** - store it in your client's secret manager. If the user lacks `mcp:use`, the API rejects with 403 and the MCP preflight surfaces the error to stderr before the transport spins up. {/* screenshot: the Generate API key dialog with the mcp:use scope */} ### Pick a delivery mode [#pick-a-delivery-mode] | Mode | Transport | Who runs the process | Auth | Use when | | -------------------------------------- | ------------------------------ | --------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | **Local-Proxy** | stdio | Each operator's laptop | API key in env | Single user, talking to a remote orboto instance from one client | | **Self-Hosted-Inline (OAuth)** | HTTP (Streamable per MCP spec) | One container alongside the orboto deployment | OAuth 2.1 + PKCE via the client's built-in connector UI | Default for Claude Desktop / Cursor / VS Code - operator pastes the `/mcp` URL, browser handles consent | | **Self-Hosted-Inline (static Bearer)** | HTTP | Same | Per-request `Authorization: Bearer orb_*` header | Headless / CI bots that can't run an interactive browser flow | Both modes use the same `@orboto/mcp` build - only the transport and auth model differ. Pick Local-Proxy for a quick personal setup against a remote instance; pick Self-Hosted-Inline when you're running the orboto deployment yourself and want every operator to connect through one shared URL. ## Local-Proxy setup (stdio) [#local-proxy-setup-stdio] The MCP process runs on your laptop, spawned by the AI client over stdio. It authenticates one of two ways: * **OAuth login - recommended for people.** Omit `ORBOTO_API_KEY`. On first use the proxy runs a browser-assisted loopback OAuth flow through your workspace login (which is your SSO login when SSO is configured), then caches a short-lived, self-refreshing session at `~/.config/orboto/mcp-oauth.json` (`0600`). No token pasted, nothing long-lived. Set `ORBOTO_MCP_NO_BROWSER=1` on headless hosts to print the authorization URL instead of opening a browser; `ORBOTO_AUTH=oauth` forces this path even when a key is present. * **API key - service accounts / CI.** Embed an `orb_…` key (below). Best for headless machines with no interactive user. ### Recommended (people) - OAuth login, no token [#recommended-people---oauth-login-no-token] ```json { "mcpServers": { "orboto": { "command": "npx", "args": ["-y", "@orboto/mcp"], "env": { "ORBOTO_API_URL": "https://orboto.example.com/api", "ORBOTO_MCP_CLIENT": "claude-desktop" } } } } ``` ### API key - `npx @orboto/mcp` [#api-key---npx-orbotomcp] Since `@orboto/mcp` is published to npm, the AI client can spawn the package directly without a local clone: ```json { "mcpServers": { "orboto": { "command": "npx", "args": ["-y", "@orboto/mcp"], "env": { "ORBOTO_API_URL": "https://orboto.example.com/api", "ORBOTO_API_KEY": "orb_…", "ORBOTO_MCP_CLIENT": "claude-desktop" } } } } ``` Pin to your server's exact version (`"@orboto/mcp@0.89.1"`) for reproducible setups; use `@latest` only when your orboto host always runs the most recent release. On mismatch (package newer than server) some newer tools surface 404 at call time - the older ones keep working. ### Claude Desktop [#claude-desktop] Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows) with either JSON block above. Quit and relaunch Claude Desktop - the orboto server appears in the connector menu, and expanding it shows the connected tool list. ### Cursor [#cursor] `.cursor/mcp.json` in the workspace root (or `~/.cursor/mcp.json` for global): ```json { "mcpServers": { "orboto": { "command": "npx", "args": ["-y", "@orboto/mcp"], "env": { "ORBOTO_API_URL": "https://orboto.example.com/api", "ORBOTO_API_KEY": "orb_…", "ORBOTO_MCP_CLIENT": "cursor" } } } } ``` Cursor reads the file on every chat session start; no restart required after editing. ### GitHub Copilot Chat (VS Code) [#github-copilot-chat-vs-code] `.vscode/mcp.json`: ```json { "servers": { "orboto": { "type": "stdio", "command": "npx", "args": ["-y", "@orboto/mcp"], "env": { "ORBOTO_API_URL": "https://orboto.example.com/api", "ORBOTO_API_KEY": "orb_…", "ORBOTO_MCP_CLIENT": "vscode-copilot" } } } } ``` `ORBOTO_MCP_CLIENT` is purely a User-Agent suffix - handy for filtering the API audit log by client. ### Codex CLI [#codex-cli] Codex (the OpenAI coding agent / VS Code ChatGPT extension) reads TOML from `~/.codex/config.toml`. Add an `[mcp_servers.orboto]` table pointing at the stdio local-proxy - **stdio is the recommended transport for Codex**; see [Client compatibility](#client-compatibility) for why HTTP+OAuth is rougher on Codex today. ```toml [mcp_servers.orboto] command = "npx" args = ["-y", "@orboto/mcp"] env = { ORBOTO_API_URL = "https://orboto.example.com/api", ORBOTO_API_KEY = "orb_…", ORBOTO_MCP_CLIENT = "codex" } ``` The `orb_…` value is a service-account API key (see [Mint an MCP API key](#mint-an-mcp-api-key)) - it gives Codex zero OAuth churn on headless/CI hosts. For a personal login instead, omit `ORBOTO_API_KEY` and the proxy runs the same browser-assisted OAuth flow as the other clients above (set `ORBOTO_MCP_NO_BROWSER=1` on a headless box to print the URL). Instead of hand-editing the TOML you can run `codex mcp add` from the CLI (see `codex mcp add --help` for the current flag syntax) - it writes the same `[mcp_servers.orboto]` block. Restart Codex after either method; the orboto tools appear in its MCP list. ## Self-Hosted-Inline setup (HTTP) [#self-hosted-inline-setup-http] One MCP container per orboto deployment, listening on port 3100 internally. The web container's nginx proxies `/mcp` on the main host straight to it - same pattern as `/api` → `api:3000`. Each MCP client supplies its own Bearer token per session, so multiple operators share one container without sharing keys. Operators get **one URL, one cert, one DNS record** per deployment. The MCP server lives at `https:///mcp` (where `` is whatever your existing API/web lives on - e.g. `orboto.example.com`). No separate subdomain to manage. ### Spin up the container [#spin-up-the-container] The MCP service ships alongside the standard orboto deployment: ```bash docker compose up -d mcp ``` The container reads: | Variable | Required | Effect | | ---------------------- | -------- | --------------------------------------------------------------------- | | `ORBOTO_API_URL` | yes | Base URL of the orboto API. Inside compose, that's `http://api:3000`. | | `ORBOTO_MCP_TRANSPORT` | yes | Set to `http`. The container default already has this. | | `ORBOTO_MCP_PORT` | no | Listen port (default `3100`). | On Coolify, the web container's nginx already routes `/mcp` → `mcp:3100` so the MCP server is reachable on the main orboto URL the moment you deploy. **No subdomain to configure**. If you do want a dedicated MCP subdomain (e.g. `mcp.`) - handy for clients that don't follow path-based routing well - set the Coolify magic variable `SERVICE_FQDN_MCP_3100` to a hostname; Coolify allocates the cert + DNS. Both routes coexist. ### OAuth via the connector UI (default) [#oauth-via-the-connector-ui-default] 1. Claude Desktop → **Settings → Connectors → Add custom connector** 2. Paste `https:///mcp` 3. Browser opens to orboto, prompts you to log in if not already, then shows a consent screen ("Authorize Claude Desktop") 4. Click **Authorize** → returns to Claude Desktop. The tool list populates within seconds. {/* screenshot: the orboto MCP consent screen with the identity picker */} Cursor and VS Code do the same flow under **Settings → Tools and Integrations → Add MCP server** (Cursor) or `MCP: Add Server` from the command palette (VS Code) - just paste the URL. No API key, no operator-pre-config - each operator authorizes once per AI client install, and the resulting token rotates automatically. **Which identity you get.** This matters more than the transport. OAuth via `/mcp` signs you in as your own orboto user, and the consent screen lets you **choose the identity the connection acts as**: yourself (the default - every ticket, comment, timer, and doc is attributed to you, with your exact permissions and ACLs), or an **agent account you own**. Choosing an agent account attributes every action, timer and time entry of that connection to the agent instead of you - your personal timer and timesheet stay untouched - and the connection runs with the agent's own permissions. The grant is audited with both identities, `whoami` reports both, Profile → Connected AI clients shows an "acting as" badge, and the delegation dies immediately if the agent account is deactivated, re-owned, or you are deactivated. A static `orb_…` Bearer token (below) acts as **whatever account minted the key** - still the right choice for headless bots and CI that never see a browser. Same tool surface either way; only the acting identity differs. ### Static Bearer for CI / headless bots [#static-bearer-for-ci--headless-bots] ```json { "mcpServers": { "orboto": { "type": "streamable-http", "url": "https://orboto.example.com/mcp", "headers": { "Authorization": "Bearer orb_…" } } } } ``` The `orb_*` API key path stays supported alongside OAuth - the `/mcp` endpoint accepts both. Use it for service-account bots where there's no interactive user to walk through the consent screen. If you opted into the dedicated subdomain via `SERVICE_FQDN_MCP_3100`, swap the URL for `https://mcp.orboto.example.com/mcp` - both endpoints work identically. ### Health probe [#health-probe] `GET /health` on the MCP container returns `{"status":"ok"}` without auth. It's used by the docker-compose healthcheck; it's intentionally NOT exposed through the `/mcp` nginx route (the public path passes only `/mcp` through). To probe end-to-end, POST an `initialize` request: ```bash curl -X POST https://orboto.example.com/mcp \ -H "Authorization: Bearer orb_…" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' # → returns the server's capabilities + protocol version ``` ## Tools, resources, and prompts [#tools-resources-and-prompts] ### What's exposed [#whats-exposed] * **A curated tool manifest by default** - the measured high-frequency set (\~28 tools: the ticket loop, search + OQL query, time tracking, doc read, milestone create, session/identity) plus the two escape-hatch tools below. Most MCP clients load EVERY tool schema at connect time; the curated manifest costs them \~9k tokens instead of \~47k for the full set. The full named-tool manifest stays one opt-in away (see below). * **The escape hatch: the ENTIRE REST API stays reachable.** `orboto_api_search` finds any endpoint + its schema in the live OpenAPI spec; `orboto_api_call` executes it through the API's full permission chain. See below for a worked example. * **The full named-tool manifest (\~171 tools) is one opt-in away** - see "Choosing the toolset" below. * **4 resources** - `ticket`, `doc`, `project`, `search` - fetchable by URI for clients that prefer the resource pattern over tool calls. * **5 prompts** - `plan-sprint`, `triage-my-tickets`, `summarize-project`, `estimate-ticket`, `find-duplicates`. Pre-baked instruction templates the client can invoke with parameters. ### Choosing the toolset [#choosing-the-toolset] | Mode | Tools | Connect-time schema cost (measured 2026-08-10) | How to select | | ------------------- | ----- | ---------------------------------------------- | ------------- | | `curated` (default) | 28 | 37.5k chars, \~9.4k tokens | nothing to do | | `full` | 171 | 189.7k chars, \~47.4k tokens | see below | * **HTTP (remote) clients**: append `?toolset=full` to the endpoint URL - e.g. `claude mcp add --transport http orboto https:///mcp?toolset=full` - or send an `x-orboto-toolset: full` header. Per-connection; no server access needed. * **stdio (Local-Proxy)**: set `ORBOTO_MCP_TOOLSET=full` in the server's `env` block. * **Server-wide default**: an operator can set `ORBOTO_MCP_TOOLSET=full` on the MCP container to flip the default for every connection that doesn't choose explicitly. Deferred-loading clients (clients with tool search) pay almost nothing for the full manifest - `full` is a fine choice there. Eager-loading clients (Claude Desktop, Cursor, Codex) pay the whole manifest on every conversation turn - stay on `curated` unless you really use the long tail as named tools. ### The escape hatch (api\_search + api\_call) [#the-escape-hatch-api_search--api_call] Anything not in the curated manifest is still one search away. Worked example - trigger a backup (an admin tail operation with no named tool in the curated set): 1. `orboto_api_search` with `{"query": "trigger backup"}` returns, among others: `POST /admin/backup/run [admin:backup:write] - ...` 2. Optional: `orboto_api_search` with `{"path": "/admin/backup/run", "method": "POST"}` returns the full request/response schema. 3. `orboto_api_call` with `{"method": "POST", "path": "/admin/backup/run", "body": {...}}` executes it. The response envelope carries the inner HTTP status + body verbatim - a `403` means the API key lacks the permission, exactly as it would on a direct REST call. Guardrails: the proxy only dispatches to routes that exist in the live OpenAPI spec, refuses auth/OAuth/setup/webhook/transport paths outright, is rate-limited (120/min), and never exceeds the caller's own permissions - enforcement happens in the API's route handlers, not in the MCP layer. ### Live resource subscriptions - which clients benefit [#live-resource-subscriptions---which-clients-benefit] The server pushes `notifications/resources/updated` for `orboto://ticket/`, `orboto://doc/`, `orboto://project/`, `orboto://timer`. AI clients that subscribe to a resource get notified the moment the underlying ticket / doc / etc. changes - no polling. The catch: only AI clients with an **always-on connection AND their own event loop** can act on these pushes. Most interactive chat UIs don't have that shape. Quick table: | Client | Benefits from live push? | Why | | ------------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Autonomous agent loops (persistent worker processes, daemons) | **Yes** | Own event loop; subscribes to resources and reacts between turns. | | Cursor Background Agent Mode | **Yes** | Runs in the background, holds subscriptions, reacts on events. | | Custom MCP bots (a chat-tool bridge, a bot process) | **Yes** | Persistent process, full control over the connection. | | **Claude Desktop** | **No** | Turn-based: only calls tools inside a user-triggered turn. Even if a push arrived between turns, there's no event loop to feed it into context. | | **ChatGPT Desktop**, **Claude.ai** web | **No** | Same turn-based execution model. | | **VS Code Copilot Chat** | **No** | Turn-based. | For turn-based clients the value orboto-via-MCP delivers is **fresh tool-call data on demand** - the model calls `orboto_get_ticket` inside the current turn and sees current state. That's still genuinely useful (no copy-pasting orboto context), just not push. If you need true push-driven behaviour (e.g. "a chat bot tells the team when ticket priority flips to blocker"), build a dedicated MCP consumer - a Node script using the MCP SDK's client library that calls `resources/subscribe` and logs the resulting `notifications/resources/updated` events. That's the audience the push path is designed for. ## Verifying the connection [#verifying-the-connection] In any client, ask the AI: **"List my orboto projects."** The model picks the `orboto_list_projects` tool, the call goes through the MCP server → REST API → back, and the projects you're a member of come back as a numbered list - something like: ``` 1. ACME - Website Rebuild (12 open tickets) 2. ACME - Internal Tools (3 open tickets) ``` You've now confirmed all three legs work: the client can reach the MCP process, the MCP process can reach your orboto instance, and the API key (or OAuth identity) resolves to an account with real project memberships. A second good check is asking for something ticket-shaped - **"What's in ACME-1?"** - to confirm reads flow through correctly before you rely on it for a real task. If you see "no projects matched" but you know you're a member of some, the auth is working but the API key may be from the wrong user - re-check who the key belongs to in **Profile → API keys**. ## Troubleshooting [#troubleshooting] ### Client compatibility [#client-compatibility] HTTP+OAuth (Self-Hosted-Inline) is the default for Claude Desktop, Cursor, and VS Code Copilot - those clients handle the OAuth session, session-expiry re-init, and token refresh reliably. For stdio-first CLIs (Codex and similar), prefer the stdio local-proxy (`npx @orboto/mcp`) instead of an HTTP+OAuth URL - it avoids the OAuth-session churn some CLIs handle poorly. Both transports reach the same tool surface. Codex has a first-class walkthrough above: [Codex CLI](#codex-cli). | Symptom | Fix | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Client shows "MCP server failed to start" | Check `ORBOTO_API_URL` is reachable from the laptop / container, and the API key is `orb_…`-shaped. The MCP process runs a preflight against `/users/me` and prints the failure to stderr - capture it via the client's MCP log panel. | | `401 unauthorized` on every tool call | Key revoked, expired, or copied wrong. Mint a new one. | | `403 mcp:use scope required` | The owner of the API key doesn't have the `mcp:use` permission. Super-admin: assign a role that includes it (e.g. the bundled `developer` or higher). | | Tools list is empty in the client | Check the client's connector/MCP panel shows the server as connected. If yes, the server connected but no tools landed - usually a server-side tool-registration error; check the client's MCP log for the failure. | | `mcp-session-id` errors (HTTP transport) | Some clients re-issue `initialize` mid-session, breaking the per-session connection. Update to the latest client version - this was fixed in MCP SDK ≥ 1.29. | | "OAuth authorization required" repeatedly / client keeps asking to re-authorize (HTTP+OAuth) | Refresh-token rotation used to revoke the whole token family on any duplicate / concurrent / lost-response refresh (parallel agents, flaky network), forcing a full re-auth. A server-side rotation grace window means a just-rotated token re-presented within \~60s returns a working token pair instead of revoking the family - make sure your server is on a current release. A client whose family was revoked before that must reconnect once (disconnect + reconnect the connector, or restart the client). Also confirm **Profile → "Connect an AI Client"** is enabled for the account - a disabled MCP kill-switch refuses token renewal. | | Client loses the orboto tools after a while, or after a server deploy | orboto's MCP HTTP endpoint is spec-compliant: it sends the RFC 6750 `WWW-Authenticate` challenge with the `resource_metadata` URL, serves OAuth `.well-known` discovery, and survives deploys via session rehydrate / auto-adopt so a valid-token client re-establishes transparently. But a rough client that does not re-initialize its MCP session on a session-expiry 404, or does not refresh its token on a 401, can strand with no tools - typically after a deploy once the access token has also aged. Known-good over HTTP+OAuth: Claude Desktop, Cursor, VS Code Copilot. If Codex CLI over HTTP+OAuth strands, connect it via the STDIO local-proxy (`npx @orboto/mcp`) instead, which it handles reliably - use a service-account `orb_` API key (`ORBOTO_API_KEY`) for zero OAuth churn, or omit it for OAuth login. Recover a stranded client: disconnect + reconnect the connector, or fully restart the client (forces a fresh MCP `initialize`). | | Stdio mode hangs on first tool call | Almost always stray output on stdout corrupting the JSON-RPC stream (a log line accidentally sent to stdout instead of stderr). Run the binary outside the AI client first; a startup line should go to stderr but stdout should be silent until the client connects. | **Capturing logs for a bug report:** run the client with verbose / debug logging so you can see whether it re-initializes its MCP session on a session-expiry 404 and refreshes its token on a 401; pair that with the server-side MCP logs and attach both when reporting. ## See also [#see-also] * The agent skill (downloadable from your instance) - the REST-API workflow for autonomous, non-MCP agents. * [Work routing and fleets](/agents-ai/work-routing) - how agents pull and lease work through the same permission model. # Run an agent fleet (/agents-ai/run-a-fleet) [Work routing and fleets](/agents-ai/work-routing) explains how orboto lets agents pull work safely - leases, routing labels, review lanes, pause switches. This page covers the other half: actually **running** the agents that do that pulling, on a server you control. ## What pi-runner is [#what-pi-runner-is] `orboto pi-runner` is a small supervisor process, built into the [orboto CLI](/api-cli/cli), that keeps one coding-agent session alive and feeds it work. Concretely, it: * starts and supervises a [`pi`](https://pi.dev) coding-agent session (the integrated coding-agent runtime orboto's fleet tooling drives), * delivers messages from that identity's orboto **agent inbox** to the session as it works (steering it if it's mid-turn, prompting it if it's idle), * in **fleet mode**, also self-tasks the session: once it has sat idle for a configurable number of minutes, pi-runner injects a prompt that tells it to pull its next ticket with `work-next` - the same pull [Work routing and fleets](/agents-ai/work-routing) describes, just triggered by a timer instead of a human, * automatically switches to a fallback model when the current one hits a provider rate limit or quota error, so a fleet running overnight doesn't just stall on a 429, * resumes its session on restart instead of starting over, and keeps its working directory independent of wherever the supervisor itself runs. One running `pi-runner` process is one **lane**: a single agent identity, working a single project, in a single role (implementer, reviewer, and so on). A fleet is simply several lanes running side by side, each with its own configuration. ## Prerequisites [#prerequisites] * A Linux server you control - any VM, dedicated box, or container host. No specific provider is required; this page is written to work anywhere. * The [orboto CLI](/api-cli/cli) installed on that server. * The `pi` coding agent installed: `npm install -g @earendil-works/pi-coding-agent`. `pi-runner` shells out to the `pi` binary, so it must be on the `PATH` of whichever user runs the lane. * API keys for whichever LLM provider(s) your lanes will use (the coding agent reads these from its own environment, same as running `pi` interactively). ## Step 1: create one bot identity per lane [#step-1-create-one-bot-identity-per-lane] Every lane authenticates to orboto as its own **bot identity** - a [user account](/admin/identity/users-and-roles) created for automation rather than a person. Give each lane its own identity rather than sharing one across lanes: * Activity, comments, and commits stay attributable to the specific lane that did the work. * You can pause or revoke one lane without touching any other. * Review lanes work correctly: `work-next --role review` refuses to hand a reviewer its own identity's implementation work, which only means anything if implementer and reviewer are different identities. For each lane: create a bot user (or service account), give it project membership with the role it needs (implementer, reviewer, and so on), and mint an API key for it from that user's profile. Keep the key somewhere you can paste it into the lane's environment file in the next step. {/* screenshot: creating a bot / service-account user with an API key in the admin Users page */} ## Step 2: smoke-test one lane by hand [#step-2-smoke-test-one-lane-by-hand] Before wiring up a systemd fleet, run one lane in a terminal to confirm the pieces fit together: ```bash export ORBOTO_BASE_URL=https://your-orboto-host.example.com/api export ORBOTO_TOKEN=orb_xxxxxxxxxxxxxxxxxxxxx # the bot's API key orboto pi-runner --project ACME --session-name smoke-test ``` You should see status lines on stderr as `pi` starts up and the runner connects to orboto's agent-message stream. Leave it running, send the bot a message with `orboto agent-notify "hello"` from another terminal (using an account that isn't the lane itself), and confirm the runner logs a delivery. Stop it with Ctrl-C once you've confirmed it works - this was just a connectivity check, not a real lane. ## Step 3: configure a lane [#step-3-configure-a-lane] Every knob `pi-runner` has is available either as a CLI flag or as an `ORBOTO_AGENT_*` environment variable (the flag wins if both are set). Env vars exist so a lane's entire configuration can live in one file, which matters once you're running several lanes under systemd (Step 5). | Flag | Env var | What it controls | | ------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--project` | `ORBOTO_AGENT_PROJECT` | The project key this lane works. Required. | | `--work-role` | `ORBOTO_AGENT_ROLE` | `implementation` (default), `review`, `preflight`, or `integration` - see [Worker vs. review lanes](#worker-vs-review-lanes) below. | | `--agent-tag` | `ORBOTO_AGENT_TAG` | The routing tag this lane prefers, e.g. `backend` - matches the `agent:backend` label from [Work routing](/agents-ai/work-routing#routing-labels). | | `--workdir` | `ORBOTO_AGENT_WORKDIR` | Directory `pi` is spawned in, independent of wherever the supervisor process itself runs. Created if missing. | | `--repo` | `ORBOTO_AGENT_REPO` | A git URL to clone into `--workdir` on first start, and fetch + fast-forward on every restart. Requires `--workdir`. Use a token embedded in the URL for a private repo; a read-only deploy token is enough for a lane that only needs to read code. | | `--idle-prompt` | `ORBOTO_AGENT_IDLE_PROMPT` | The self-tasking prompt injected once the session has been idle this long. Leave unset to run a lane that only reacts to inbox messages and never self-tasks. | | `--idle-after` | `ORBOTO_AGENT_IDLE_AFTER` | Minutes of idle time before `--idle-prompt` fires (default 30). Fires again every interval while the session stays idle. | | `--model` | `ORBOTO_AGENT_MODEL` | The model the `pi` child uses, as `provider/modelId`. | | `--fallback-models` | `ORBOTO_AGENT_FALLBACK_MODELS` | Comma-separated `provider/modelId` chain to switch through automatically when the current model hits a rate limit or quota error. | | `--poll` | - | Poll interval in seconds, used only as a fallback while the live event stream reconnects (default 15). | | `--session-name` | - | The `pi` session id, used to create-or-resume a session per working directory across restarts. | | `--bootstrap` | - | A one-time prompt delivered when the lane first starts. | | `--ref` | `ORBOTO_SENDER_REF` | Identifies this lane as a message sender, so it never wakes itself up from its own outbound replies. Defaults to a generated value; you don't normally need to set it. | The `--idle-prompt` and `--agent-tag`/`--work-role` combination is what turns a lane into a **fleet** lane rather than a purely reactive one: with both set, `pi-runner` appends the exact `work-next` pull command to your idle prompt automatically, so the agent knows how to fetch its next ticket without you writing that instruction yourself. ## Step 4: worker vs. review lanes [#step-4-worker-vs-review-lanes] The `--work-role` a lane pulls with decides what kind of ticket it sees: * **`implementation`** (the default) - pulls open, unclaimed tickets and does the actual work: it's the role that produces the commit. * **`review`** - pulls tickets waiting for review instead of open ones, and never a ticket its own identity implemented (see [Review lanes](/agents-ai/work-routing#review-lanes)). Always run review lanes under a separate bot identity from the implementer lanes they check. * **`preflight`** and **`integration`** - attach to a ticket without reassigning it or moving its status, for lanes that validate or integrate work alongside the implementer rather than owning the ticket themselves. A fleet commonly runs more implementer lanes than review lanes, since review work is faster per ticket. Give each role its own `--agent-tag` if you want finer routing than role alone provides (e.g. `backend-worker` vs. `frontend-worker`, both role `implementation`). ## Step 5: deploy under systemd [#step-5-deploy-under-systemd] orboto ships a generic systemd template unit plus an example environment file so the same binary and unit serve every lane - only the environment file differs per lane. 1. **Create the service user** the lanes run as: ```bash sudo useradd -r -m -d /var/lib/orboto-agents -s /usr/sbin/nologin orboto-agent ``` 2. **Install the binary** at `/usr/local/bin/orboto` (however you normally install the orboto CLI on this host). 3. **Copy the template unit.** The unit ships in the orboto source tree at `deploy/pi-fleet/orboto-agent@.service` - copy it to `/etc/systemd/system/orboto-agent@.service` on the server. It's a [systemd template unit](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Description): the `@` lets you start any number of instances (`orboto-agent@worker-1`, `orboto-agent@review-1`, ...), each reading its own environment file at `/etc/orboto/agents/.env`. 4. **Write one environment file per lane** at `/etc/orboto/agents/.env`, based on the example file shipped at `deploy/pi-fleet/example.env`. At minimum, set: ```bash # --- orboto connection (mint per lane: one bot identity per runner) --- ORBOTO_BASE_URL=https://your-orboto-host.example.com/api ORBOTO_TOKEN=orb_xxxxxxxxxxxxxxxxxxxxx # --- what this lane works on --- ORBOTO_AGENT_PROJECT=ACME ORBOTO_AGENT_ROLE=implementation ORBOTO_AGENT_TAG=worker # --- workspace --- ORBOTO_AGENT_WORKDIR=/var/lib/orboto-agents/worker-1/acme ORBOTO_AGENT_REPO=https://user:token@git.example.com/acme/acme.git # --- runner behavior --- ORBOTO_AGENT_IDLE_AFTER=15 ORBOTO_AGENT_IDLE_PROMPT=You are an autonomous worker lane. Pull your next ticket and work it end to end per the workspace rules. ORBOTO_AGENT_MODEL=your-provider/your-model ORBOTO_AGENT_FALLBACK_MODELS=your-provider/your-model,your-other-provider/their-model ``` Lock the file down - it holds an API key and, if you use one, a repo token: `sudo chmod 600 /etc/orboto/agents/.env` and confirm it's owned by `root`. Add whichever LLM provider API key(s) your `ORBOTO_AGENT_MODEL` / `ORBOTO_AGENT_FALLBACK_MODELS` chain needs (e.g. `OPENAI_API_KEY=...`) to the same file - `pi` reads them from the process environment exactly as it would running interactively. 5. **Reload systemd and start each lane:** ```bash sudo systemctl daemon-reload sudo systemctl enable --now orboto-agent@worker-1 sudo systemctl enable --now orboto-agent@review-1 ``` The unit's `%i` is the lane name after the `@` - `worker-1` above reads `/etc/orboto/agents/worker-1.env`. ## Watching lanes [#watching-lanes] * **Logs**: `journalctl -u orboto-agent@ -f` - the runner prefixes every status line with a timestamp, and echoes a condensed view of what the agent said and which tools it called, so you can follow a lane's progress without attaching to the session directly. * **Active leases**: `orboto work-sessions --mine` (run as, or with the token of, the lane's own identity) shows exactly what that lane currently holds; `orboto work-sessions --ticket ACME-42` shows who - if anyone - holds a specific ticket. * **The workspace UI**: tickets a lane is actively working show its identity as assignee, same as a human's would. ## Pausing [#pausing] Use the same [per-bot or workspace-wide pause switches](/agents-ai/work-routing#pausing) as any other agent - they're identity-level, not runner-level, so they work the same whether the identity is connected through `pi-runner`, the MCP server, or anything else. You do **not** need to stop the systemd service to pause a lane: `pi-runner`'s idle-prompt loop keeps firing on schedule, the resulting `work-next` pull comes back `autonomy_paused`, and the lane simply stays idle until you unpause it - no restart needed either way. ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pi-runner` exits immediately with "is pi installed?" | The `pi` binary isn't on the `PATH` of the user running the service. Confirm `sudo -u orboto-agent pi --version` works. | | A lane never self-tasks, only reacts to inbox messages | `--idle-prompt` (or `ORBOTO_AGENT_IDLE_PROMPT`) is unset - without it, `pi-runner` runs in purely reactive mode by design. Set it plus `--agent-tag` or `--work-role` to enable fleet mode. | | `--repo requires --workdir` at startup | `ORBOTO_AGENT_REPO` is set without `ORBOTO_AGENT_WORKDIR`. A repo clone always needs an explicit target directory. | | The lane keeps working the same ticket status forever | Check `journalctl` for repeated `work-next` calls returning `all-blocked` or `all-leased` - see the reason table in [Work routing](/agents-ai/work-routing#the-pull-work-next). | | The lane hits a provider limit and never recovers | Set `--fallback-models` / `ORBOTO_AGENT_FALLBACK_MODELS` to a chain of alternate models. Without it, a limit error is only logged - the lane doesn't switch on its own. | | Fresh checkout fails on first start | `git clone` failed - check the `ORBOTO_AGENT_REPO` credentials and that the lane's user can reach the git host. A failed clone is treated as fatal so a lane never silently works on an empty directory. | | Restart loses recent conversation context | Confirm `--session-name` (or the unit's `%i`) is stable across restarts - `pi-runner` resumes a session keyed on this name plus its working directory; a changed name starts a fresh session. | # Work routing and fleets (/agents-ai/work-routing) orboto's dispatch model lets a pool of agents work a backlog without a human assigning every ticket - safely. ## The pull: work-next [#the-pull-work-next] An agent asks for the best available ticket in a project and receives it with an atomic **lease** plus the full context bundle (workspace rules, project primer, the ticket, its checklists and dependencies). Two agents can never receive the same ticket - the lease is decided by the database, not by timing. When nothing is ready the agent gets a structured "nothing ready" answer with a machine-readable reason instead of an error, so a calling loop can decide what to do next rather than parsing an error message: | Reason | Meaning | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `all-blocked` | Every open ticket has an unresolved dependency - nothing is actually workable yet. | | `all-leased` | There's open work, but every matching ticket already has an active lease held by another agent. | | `none-matching` | Nothing in the backlog matches the role/tag you pulled with (e.g. you asked for `--role review` and nothing's waiting for review). | | `autonomy_paused` | A pause switch is on - see [Pausing](#pausing). | From the CLI: ```bash orboto work-next ACME # pull the best ready ticket in ACME # exit code 3 = nothing ready right now (empty backlog, or paused) ``` The MCP surface exposes the same pull as a tool call, so an MCP-connected agent asks for work the same way a CLI-driven one does. ### What the lease actually guarantees [#what-the-lease-actually-guarantees] A lease is a time-boxed, exclusive hold on one ticket for one role (implementer or reviewer - see [Review lanes](#review-lanes)). While an agent holds the implementer lease on `ACME-42`, no other agent's `work-next` or `work-start` can also receive it - the database enforces this as an atomic operation, not a check-then-act race that timing could break. The full pull-work-release cycle from the CLI: ```bash orboto work-next ACME # pull + lease a ticket, or exit 3 if nothing's ready orboto work-start ACME-42 # (alternative) lease a SPECIFIC ticket instead of pulling # ...do the work... orboto work-finish --commit abc1234 --status done --tests --notes "added regression test" ``` `work-finish` releases the lease and records what happened - the commit, which checks you ran (`--build` / `--tests` / `--lint`), and free-text notes. This is the evidence trail: anyone looking at the ticket later sees not just "done" but what was verified and how. **If a lease sits unrenewed past its timeout** (the agent crashed, lost its network connection, or simply never called `work-finish`), it expires and the ticket becomes pullable again - work isn't permanently stuck behind a dead agent. Reclaim an expired lease explicitly instead of waiting for a fresh pull to notice it: ```bash orboto work-start ACME-42 --takeover ``` **If you want a specific ticket that's currently leased by someone else** instead of failing immediately, queue for it rather than bailing: ```bash orboto work-start ACME-42 --on-conflict queue ``` The default (`--on-conflict reject`) fails fast instead, which is usually what you want in an automated loop - better to move on to the next `work-next` pull than block waiting for one specific ticket. Check what's currently leased, by anyone or just by you: ```bash orboto work-sessions --ticket ACME-42 # who (if anyone) holds ACME-42 right now orboto work-sessions --mine # everything you currently hold ``` ## Routing labels [#routing-labels] * `agent:` - **preference, not exclusivity**: agents pulling with that tag get the ticket first; other agents still take it when nothing better is available. * `human` - **hard lock**: agents never pull this ticket on their own. A human can still explicitly hand it to an agent, which then sees a clear warning that it is working reserved work. * Unlabeled tickets are open to every agent (opt-out model). Both labels render with distinct icons on cards, and the ticket view has a one-click "reserve for humans" toggle. {/* screenshot: a ticket card showing the agent:backend and human label icons */} Attach either label the same way you'd attach any other label - from the ticket view, through an MCP client's ticket-labeling tool, or via the label endpoints in the REST API. **Example: route backend bugs to a specific agent lane.** Label a batch of tickets `agent:backend`, then have that lane's agent pull with the matching tag so it always prefers its own queue over the general backlog: ```bash orboto work-next ACME --agent-tag backend ``` **Example: keep a sensitive ticket human-only.** Label it `human` from the ticket view. No agent lane will pull it via `work-next`; a human still assigns it directly when they want an agent on it anyway. ## Review lanes [#review-lanes] An agent pulling with the **review** role receives tickets waiting for review instead of open ones - and never a ticket its own identity implemented. Run review lanes under a separate bot identity (ideally a different model) so every change gets an independent check. ```bash orboto work-next ACME --role review # pull from the review lane, not the open backlog ``` ## Pausing [#pausing] Per-bot and workspace-wide pause switches stop all self-directed pulls while explicitly assigned work continues - see [Agents administration](/admin/ai/agents). The distinction that matters operationally: pausing gates **only** `work-next` (the pull). It has no effect on `work-start` against a specific ticket, on a lease an agent already holds, or on a human assigning a ticket to an agent directly - a paused agent can still be told exactly what to do, it just stops helping itself to new work. * **Workspace-wide** stops every agent's self-directed pulls at once - the switch a maintenance window reaches for. * **Per-bot** stops just one identity - useful when a specific agent is misbehaving (picking bad tickets, producing low-quality work) and you want it off the backlog while you investigate, without halting every other agent. Both are instant: a pull attempted the moment after either switch flips returns `autonomy_paused` as the reason, with no propagation delay to wait out. ## Feeding the fleet [#feeding-the-fleet] Recurring work reaches the fleet as ordinary tickets - created manually, on a schedule via the [n8n node](/integrations/n8n), or from [inbound email](/admin/email/inbound-email) - carrying the routing label for the lane you want. **Example: a scheduled n8n workflow files a nightly cleanup ticket** with `agent:backend` already attached, so the next `work-next` pull in that lane picks it up without anyone assigning it by hand. ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `work-next` keeps returning "nothing ready" but the board has open tickets | Read the `reason` field first - it tells you exactly which of the four cases you're in (see the table above). `autonomy_paused` means check the pause switches under [Agents administration](/admin/ai/agents); `all-leased` means the work exists but is already claimed; `all-blocked` means dependencies need resolving first. | | Two agents both claim to be working the same ticket | Shouldn't happen through `work-next` / `work-start` - the lease is atomic at the database level. If you're assigning tickets by hand instead of through the pull, use the lease-aware commands so the guarantee applies. | | An `agent:` ticket got picked up by an agent without that tag | Expected: the label is a preference, not an exclusivity lock. Use `human` if a ticket must never be touched by another lane. | | A review-lane agent reviews its own work | Run the review lane under a separate bot identity - `work-next --role review` never returns a ticket the *same* identity implemented, but it can't protect against sharing one identity across both roles. | | A fleet-fed ticket (from n8n / inbound email) never gets pulled | Confirm the routing label landed on the ticket - check the source workflow actually sets `agent:` (or leaves it unlabeled) rather than defaulting to no label plus a `human` tag left over from a template. | # CLI daily workflow (/api-cli/cli-usage) This page assumes the CLI is [installed and configured](/api-cli/cli). Run `orboto help` any time for the full, always-current command list - this page groups the same commands by task with realistic examples. Every command supports the same operations as the [agent skill](/agents-ai/agent-skill) - instructions written for one apply to the other. ## Orientation [#orientation] ```bash orboto whoami # your identity, roles, and permissions orboto primer ACME # project conventions: stack, commands, gotchas orboto rules # the workspace's binding agent rules ``` `session-start` (below) is the recommended way to begin a work session - it bundles identity, rules, and your in-progress work into one call. ## The ticket loop [#the-ticket-loop] ### Reading a ticket [#reading-a-ticket] ```bash orboto ticket ACME-42 ``` By default this prints a compact, human-readable card - not raw JSON - built from the same data the web UI shows: ``` ACME-42 [bug/high] In Progress Title: fix(auth): session expires early Assignees: Jane Doe Milestone: Sprint 5 Description (preview): session TTL was configured in minutes instead of… ``` The description preview is truncated at 200 characters. Add `--full` to get the complete, raw JSON response instead - useful when you need every field, or you're piping the output into something else with `jq`: ```bash orboto ticket ACME-42 --full | jq '.description' ``` ### Listing tickets [#listing-tickets] ```bash orboto list-tickets ACME --status todo --limit 20 orboto my-tickets --project ACME ``` `list-tickets` requires a project key and defaults to `--limit 50`; pass `--all` to remove the cap entirely (it walks every page of the cursor internally, so a very large project may take a moment). `--status` filters on the workflow category (`todo` / `in_progress` / `in_review` / `done`), not a specific per-project status name - use [`query`](#milestones-docs-and-search) with OQL's `statusName` field if you need to filter on a custom status name instead. `--parent ` narrows to one ticket's direct children, which is the fast way to see everything filed under an epic. `my-tickets` is the same idea scoped to your own assignments; `--project` is optional there - omit it to see your tickets across every project you're a member of. ### Creating a ticket [#creating-a-ticket] ```bash orboto create-ticket ACME "fix(auth): session expires early" \ --type bug --priority high --label backend --label auth \ --assign jane@example.com --milestone "Sprint 5" --due 2026-09-15 ``` Every flag `create-ticket` accepts: | Flag | Effect | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `--type` | `task` / `bug` / `story` / `epic`. | | `--priority` | `blocker` / `high` / `normal` / `low` / `trivial`. | | `--milestone` | Milestone by name or key. | | `--parent` | Parent ticket key/UUID - files this ticket as a child. | | `--label` | Repeatable - pass it once per label. | | `--assign` | Repeatable - one or more emails to assign immediately. | | `--due` | ISO date (`YYYY-MM-DD`). | | `--private` | Marks the ticket private on creation. | | `--description "..."` | Inline description text. | | `--description-stdin` | Read the description from stdin instead - use this for anything long or containing characters your shell would mangle. | | `--delivery-mode` | `implementation` / `docs` / `review` / `admin` / `epic` - what kind of work this ticket represents. | | `--allow-language-mismatch` | Override strict language enforcement (see below). | | `--allow-duplicate` + `--duplicate-justification "..."` | Override the duplicate block (see below). | **Two guardrails can block a create outright**, and both print a clear recipe for getting past them intentionally rather than a bare error: If the workspace enforces one language and your title/description reads as a different one: ``` ⛔ Blocked - strict ticket-language enforcement is on. This content reads as "en" but the workspace language is "de". Rewrite it, or re-run with --allow-language-mismatch if the language is intentional. ``` If the content looks like an existing ticket: ``` ⛔ Blocked - this looks like a duplicate. Existing tickets it overlaps with: - ACME-38 (91%): session cookie expires before the configured TTL Extend one of these instead, or re-run with --allow-duplicate --duplicate-justification "why". ``` Both are genuinely blocking (not warnings you can ignore) - the message itself tells you the exact flag to add if the create really is intentional despite the match. ### Claiming, commenting, moving, and closing [#claiming-commenting-moving-and-closing] ```bash orboto claim ACME-42 ``` `claim` does three things in one call - assigns you to the ticket, moves it to `in_progress` if it isn't already there, and starts your timer - and prints what it actually did: ```json { "key": "ACME-42", "status": "In Progress", "timerStarted": true } ``` Flags: `--sole` unassigns everyone else first (a genuine take-over, not just adding yourself); `--force` allows claiming a ticket that's currently `done` (without it, claiming a done ticket is refused outright * reopening is a deliberate action, not a side effect); `--no-timer` assigns and moves the ticket without touching the timer. ```bash orboto comment ACME-42 "found it - session TTL was set in minutes, not seconds" orboto move ACME-42 in_review orboto close ACME-42 --comment "fixed in the last commit, verified by the test suite" ``` `move ` moves between the four workflow categories - `todo` / `in_progress` / `in_review` / `done` - and, like the UI, only succeeds if that transition is actually allowed by the project's configured workflow; an unreachable transition is rejected, not silently adjusted to the nearest valid one. `close` is `move ... done` plus an optional comment posted first, so the closing note lands before the status change in the ticket's history. Before creating a ticket that might already exist, check explicitly: ```bash orboto check-similar ACME "session expires early" ``` This runs the same similarity check `create-ticket` runs automatically - useful to check before you've even written a full description. Link a dependency (the second ticket blocks the first): ```bash orboto deps ACME-42 ACME-40 ``` ## Bulk operations [#bulk-operations] Apply one change to many tickets instead of looping: ```bash orboto bulk-close ACME-10,ACME-11,ACME-12 --comment "superseded by ACME-42" ``` ```bash orboto bulk-create ACME --from=@drafts.json --milestone "Sprint 5" ``` `drafts.json` is a JSON array of draft objects (at minimum a `title` per draft; the same optional fields `create-ticket` takes apply per draft). On completion it prints a summary, not a per-row echo: ```json { "created": ["ACME-51", "ACME-52", "ACME-53"], "failed": 0, "duplicateFlagged": 1 } ``` Each failed row prints its own line to stderr (`✗ draft 2 "some title": `) without stopping the rest of the batch - a bad row is isolated, not fatal to the whole run. `duplicateFlagged` counts drafts that matched an existing ticket closely enough to warn about (also echoed to stderr per row: `⚠ ACME-53 may be a duplicate - review before treating as new work`) without blocking the create - unlike `create-ticket`'s single-item duplicate guard, a bulk create doesn't stop for you to decide, since there's no one there to answer the question; it flags for a later review pass instead. The command's exit code is `1` if anything failed, even though everything that succeeded is still created - check `failed` in the JSON, don't rely on the exit code alone to know how many landed. ```bash orboto bulk-deps --from=@blocks.txt # lines "ACME-42 ACME-40" = 42 blocked by 40 ``` Accepts either the plain-text `"A B"`-per-line form shown above, or a JSON array of `[a, b]` pairs - useful when the pairs come from another command's output. An edge that already exists is counted as OK, not an error, so re-running the same file is safe. All three bulk commands accept `--from=-` to read from stdin instead of a file - handy when a previous command's output feeds the next one, for example piping an OQL query's matched keys straight into `bulk-close`. ## Milestones, docs, and search [#milestones-docs-and-search] ```bash orboto milestones ACME orboto create-milestone ACME "Sprint 5" orboto close-milestone ACME "Sprint 5" orboto search "login timeout" --types ticket --project ACME orboto query "assignee = currentUser() AND statusCategory != done" --limit 25 orboto get-doc ACME-D3 ``` `query` runs [OQL](/api-cli/oql) directly - the same language the in-app search palette and the MCP `orboto_query` tool use. Add `--syntax jql` to paste JQL instead. ## Sessions and work routing [#sessions-and-work-routing] Sessions are how an agent claims and hands back work with an [atomic lease](/agents-ai/work-routing) rather than a plain assign - read that page for the full model (what a lease guarantees, the four "nothing ready" reasons, pausing). This section is the command-by-command mechanics. ```bash orboto session-start --project ACME # orientation digest: rules, your work, timer ``` `session-start` is the recommended way to begin any work session - one call bundles your identity, the workspace's current agent rules (or a compact "unchanged" acknowledgment if you already have them - see [Rules delivery](/agents-ai/agent-skill#rules-delivery)), your in-progress tickets, and your timer state. It prints as readable text (section headers, not raw JSON) so a human or an agent reading its own output can both use it directly. ```bash orboto work-next ACME ``` `work-next` pulls the best available ticket in a project and reserves it under a lease. On success it prints the full JSON reservation (including the ticket, its context bundle, and the session id - which the CLI also remembers locally, so `work-finish` afterward doesn't need you to pass it again). Add `--role review` to pull from the review lane instead of open work, and `--agent-tag ` to prefer `agent:`-labeled tickets (see [Work routing](/agents-ai/work-routing#routing-labels)). When there's nothing to pull, `work-next` exits with code `3` (never an error code) and explains exactly why on stderr: ``` Nothing ready (all-leased). Candidates considered: 4. Retry in ~90s (earliest known free: 2026-08-30T14:05:00Z). ``` or, if a pause switch is on: ``` Autonomous work is PAUSED for this agent (operator decision, per-bot or workspace-wide). Idle and wait for an operator instruction or an agent-notify wakeup - do not poll. Explicitly named work-start still works. ``` That second message is a deliberate instruction, not just a status: when paused, an autonomous loop should idle rather than keep polling - `work-start` against a *specific* ticket still works even while paused, since pausing only gates the self-directed pull. ```bash orboto work-start ACME-42 --role implementer ``` `work-start` leases one specific ticket instead of pulling whatever's best. Use it when a human directs an agent at a particular ticket rather than letting it choose. `--takeover` reclaims a lease that expired (the previous holder crashed or never finished) instead of failing; `--on-conflict queue` waits for a currently-held lease to free up instead of the default `reject` behavior, which fails immediately so an automated loop can move on to something else rather than block. ```bash orboto work-finish --commit abc1234 --status done --tests --notes "added regression test" ``` `work-finish` releases the lease and records the evidence that closes the loop. Without a session id argument, it releases whichever session `work-start`/`work-next` most recently reserved in this checkout - pass one explicitly only when you're finishing a session other than your most recent one. `--build` / `--tests` / `--lint` are verification flags (set the ones you actually ran - they're not assumed true), `--commit` records the commit SHA, `--notes` is a free-text explanation, and `--status` bundles a workflow move into the same call so you don't need a separate `move`/`close`. Pass `--cancel` instead of `--status` to release the lease without marking anything complete - use this when you're stopping work on a ticket without finishing it (handing it back for someone else to pick up). ```bash orboto work-sessions --ticket ACME-42 # who (if anyone) currently holds this ticket orboto work-sessions --mine # every session you currently hold orboto work-sessions --mine --include-closed # ...plus your finished/cancelled history ``` ## Supervising a headless coding agent [#supervising-a-headless-coding-agent] ```bash orboto pi-runner --project ACME --session-name my-agent \ --bootstrap "Run orboto session-start, then work tickets via the orboto CLI." \ -- --provider anthropic --model "*sonnet*" ``` Runs a headless coding-agent session and turns your orboto agent inbox into live wakeups for it: message the bot's identity with a project scope, and the running session receives it as a prompt (or a mid-turn steering message) - no MCP server needed, the session talks to orboto through this same binary. ## Agent inbox and time tracking [#agent-inbox-and-time-tracking] ```bash orboto messages --project ACME # your agent inbox orboto messages --ack , # acknowledge messages orboto agent-notify teammate@example.com "ready for review" --project ACME orboto timer # current running timer, if any orboto timer-start ACME-42 orboto timer-stop ``` ## Full API access (generic verbs) [#full-api-access-generic-verbs] The named commands above cover the frequent operations. The entire REST API stays reachable through generic verbs, so a new endpoint never needs a new CLI subcommand before it's usable: ```bash orboto get /users/me orboto post /v1/agent/notify '{"targetEmail":"bot@example.com","subject":"ready"}' echo '{"title":"A"}' | orboto post /projects//tickets - orboto patch /tickets/ '{"priority":"high"}' orboto delete /tickets//labels/ ``` The trailing `-` reads the JSON body from stdin instead of an inline argument - useful for larger payloads or piping from another command. Discover available endpoints and their schemas via your instance's `/docs` page (interactive OpenAPI reference). ## Full command reference [#full-command-reference] | Group | Commands | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identity + orientation | `whoami`, `primer `, `rules` | | Tickets | `ticket`, `list-tickets`, `my-tickets`, `create-ticket`, `claim`, `comment`, `move`, `close`, `check-similar`, `deps`, `bulk-close`, `bulk-create`, `bulk-deps` | | Milestones + docs + search | `milestones`, `create-milestone`, `close-milestone`, `search`, `query`, `get-doc` | | Sessions | `session-start`, `work-start`, `work-next`, `work-finish`, `work-sessions` | | pi supervisor | `pi-runner` | | Agent inbox + time | `messages`, `agent-notify`, `timer`, `timer-start`, `timer-stop` | | Generic API access | `get`, `post`, `patch`, `put`, `delete` | | Other | `version`, `help`, `self-update` | ## Troubleshooting [#troubleshooting] | Symptom | Fix | | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `work-next` exits with code `3` | Not an error - it means no ticket is currently ready to pull (empty backlog, or the project/workspace pause switch is on). Retry later, or check [Agents administration](/admin/ai/agents) for pause state. | | `claim` succeeds but no timer starts | Passed `--no-timer`, or a different agent instance under the same account already owns a running timer. Check `orboto timer`. | | A ticket won't move to the status you expect | `move`/`close` enforce the project's configured workflow transitions, same as the UI - list the project's statuses with `orboto get /projects//ticket-statuses` to see what's actually reachable from the current one. | | `bulk-*` reports partial failures | Bulk commands isolate errors per item - a bad row doesn't abort the rest. Re-run with just the failed keys/rows after fixing the input. | | Generic verb returns `404` for a route you can see in `/docs` | Check the base URL includes (or excludes) `/api` consistently with what your instance expects, and that the path matches exactly - trailing slashes and path params are not forgiving. | # Install the CLI (/api-cli/cli) `orboto` is a single static binary over the orboto REST API - the command-line client for agents, CI pipelines, and operators. Zero runtime dependencies, near-instant startup, full API coverage. Once it's installed and configured, head to [CLI daily workflow](/api-cli/cli-usage) for the full command set. ## Install [#install] **Install script (macOS / Linux):** ```bash curl -fsSL https://raw.githubusercontent.com/orboto/orboto/develop/install.sh | sh ``` Detects your OS and architecture, verifies the SHA-256 checksum, and installs to `/usr/local/bin` (falling back to `~/.local/bin` if that's not writable). Pin a version with `ORBOTO_CLI_VERSION=vX.Y.Z`. **npm:** ```bash npm i -g orboto ``` Installs the platform binary via optional dependencies - Node is only involved at install time, never when the CLI runs. **Direct download:** every release publishes `orboto___` binaries plus a `sha256sums.txt` checksum file on its GitHub release page (Windows included). Download the binary for your platform, verify the checksum, and put it on your `PATH`. ## Configure [#configure] The CLI reads its connection from, in priority order (first source wins per key): environment variables, `./.orboto.env` in the current working directory, then `~/.orboto/env`. ``` ORBOTO_BASE_URL=https://your-orboto/api ORBOTO_TOKEN=orb_... ``` Both are required. `ORBOTO_BASE_URL` has no default; `ORBOTO_TOKEN` is an API key minted from your profile or by an admin for a bot account. The token deliberately has no command-line flag - a flag would land in shell history and the process list. `--base-url ` overrides the URL for a single call without touching your config files. **"First source wins per key" means per key, not per file.** The three sources aren't merged as whole files - each individual variable is resolved independently, checking environment, then `./.orboto.env`, then `~/.orboto/env`, and stopping at the first place that sets it. For example, with `ORBOTO_TOKEN` only in `~/.orboto/env` (a personal default) and `ORBOTO_BASE_URL` only in `./.orboto.env` (checked into a project so everyone on it points at the same instance), a command run from that project directory picks up both - the token from your home directory, the URL from the project - without you setting either as an environment variable. Set an environment variable and it wins over both files for that one key, leaving the other key to still fall through to whichever file has it. Optional agent context - set these when the CLI runs as (or on behalf of) an AI agent: ``` ORBOTO_AGENT_KIND=coding # rule targeting: which agent rules you receive ORBOTO_MODEL_TIER=frontier # rule targeting: per-tier rule variants ORBOTO_SENDER_REF=my-session # agent inbox: hides your own outbound messages ORBOTO_AGENT_SESSION=inst-1 # work-session lease renewal + per-instance timers ``` `ORBOTO_AGENT_SESSION` doesn't need to be set by hand for most setups: when it's unset, the CLI derives a stable instance token from the machine hostname and working directory, so repeated invocations from the same checkout share one agent session automatically. Set it explicitly when you're running multiple agent instances from the same checkout and need them to keep separate timers and leases. ## Update [#update] ```bash orboto self-update # check for a new release and install it orboto self-update --dry-run # check only, don't install orboto self-update --version v1.4.0 # pin to a specific version ``` Updates are checksum-verified and applied atomically (the running binary is replaced in place, never left half-written), and never run automatically - you decide when to move to a new version. ## Exit codes [#exit-codes] Every command uses the same three exit codes, so scripts and CI steps can branch on them without parsing output: | Code | Meaning | | ---- | -------------------------------------------------------------------------------------------------------------- | | `0` | Success. | | `1` | API or runtime error. The message goes to stderr, including the server's `errorKey` when the API returned one. | | `2` | Usage error - missing config, an unknown command, or bad flags. | ## Troubleshooting [#troubleshooting] | Symptom | Fix | | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `error: ORBOTO_BASE_URL is required` (or similar) on every command | No config source had the value. Check `echo $ORBOTO_BASE_URL`, then `./.orboto.env`, then `~/.orboto/env` - the first one that sets a key wins, so a stray empty override in a closer file can shadow a working one further out. | | `401` on every call | The token is wrong, revoked, or expired. Mint a new API key from your profile (or ask an admin for a bot account key) and update the config source you're actually using. | | `command not found: orboto` after the install script | The install directory isn't on your `PATH`. The script prints which directory it used (`/usr/local/bin` or `~/.local/bin`) - add it to your shell profile if needed. | | Works locally, fails in CI | CI runners rarely persist `~/.orboto/env` between jobs. Set `ORBOTO_BASE_URL` / `ORBOTO_TOKEN` as job-level secrets/env vars instead of relying on a config file. | | `orboto self-update` reports no newer version but you expect one | Releases roll out gradually; `--version vX.Y.Z` lets you pin explicitly ahead of the automatic detection. | | You changed `ORBOTO_BASE_URL` in an env file but a value from earlier in the same file still applies | Within one file, the **first** occurrence of a key wins, not the last - if you appended a new value instead of editing the existing line, the old one is still what's used. | | A trailing slash on `ORBOTO_BASE_URL` seems to matter, or doesn't | It doesn't - the CLI trims a trailing slash automatically, so `https://acme.example.com/api` and `https://acme.example.com/api/` behave identically. If requests still fail, the problem is elsewhere (wrong host, missing `/api`, etc.), not the slash. | # API cookbook (/api-cli/cookbook) All examples use an API key: `Authorization: Bearer orb_...`. The interactive OpenAPI reference at `https:///docs` documents every route and schema. ## Create a ticket [#create-a-ticket] The project is a path parameter, not a body field - `title` is the only required field in the body, everything else is optional: ```bash curl -X POST https://acme.example.com/api/projects//tickets \ -H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \ -d '{"title":"fix: flux capacitor drifts","type":"bug","priority":"high"}' ``` The response is the full ticket object; a trimmed example of what comes back: ```json { "id": "3fa0c2e1-...", "projectId": "b1a9...", "ticketKey": "ACME-51", "ticketNumber": 51, "title": "fix: flux capacitor drifts", "type": "bug", "priority": "high", "status": "TODO", "statusCategory": "todo", "statusName": "To Do", "createdAt": "2026-08-30T09:12:04.000Z", "similarWarnings": [] } ``` `similarWarnings` is a non-empty array (not an error) when the new ticket's title/description closely matches an existing one - the create still succeeds, but you get the near-duplicate's key and similarity score to review, the same signal the CLI's `create-ticket` surfaces as a warning. **Don't have the project's UUID?** Resolve it once from the key and reuse it - ticket reads also accept the key form directly within a project (`GET /projects//tickets/by-key/ACME-42`), so you typically only need the project UUID, not every ticket's UUID: ```bash curl "https://acme.example.com/api/projects/by-key/ACME" -H "Authorization: Bearer $ORBOTO_TOKEN" # → { "id": "", "key": "ACME", "name": "...", ... } ``` ## Query with OQL [#query-with-oql] ```bash curl -X POST https://acme.example.com/api/query \ -H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \ -d '{"oql":"project = ACME AND status = TODO AND priority = high","limit":25}' ``` Response shape: ```json { "items": [ { "ticketKey": "ACME-51", "title": "...", "..." : "..." } ], "nextCursor": null } ``` `nextCursor` is `null` when you've reached the end; otherwise pass it back as `"cursor": ""` in the next request body to get the following page. See [OQL](/api-cli/oql) for the full grammar and field list - `status` (legacy enum, uppercase values like `TODO`) and `priority` (lowercase values like `high`) intentionally use different casing conventions, so copy the exact case from the [field reference](/api-cli/oql#field-reference) rather than guessing. ## Comment and close [#comment-and-close] Post a comment: ```bash curl -X POST https://acme.example.com/api/tickets//comments \ -H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \ -d '{"content":"Fixed and verified."}' ``` Moving status is a two-step lookup-then-patch, because status ids are per-project (a project can rename or reorder its workflow statuses), so you resolve the target status id by name first: ```bash curl "https://acme.example.com/api/projects//ticket-statuses" \ -H "Authorization: Bearer $ORBOTO_TOKEN" # → [{ "id": "...", "name": "Done", "category": "done" }, ...] - find the row you want ``` then PATCH the ticket with that id: ```bash curl -X PATCH https://acme.example.com/api/projects//tickets/ \ -H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \ -d '{"statusId":""}' ``` The workflow's configured transitions apply exactly as in the UI - a `400` comes back if the target status isn't reachable from the ticket's current one, the same guardrail that stops a drag-and-drop move to an invalid column on the board. ## Walk a paginated list [#walk-a-paginated-list] Every list endpoint uses the same cursor shape - request a page, read `nextCursor`, and keep going until it's `null`: ```bash curl "https://acme.example.com/api/projects?limit=50" -H "Authorization: Bearer $ORBOTO_TOKEN" # → { "items": [...50 rows...], "nextCursor": "eyJrIjoi..." } curl "https://acme.example.com/api/projects?limit=50&cursor=eyJrIjoi..." -H "Authorization: Bearer $ORBOTO_TOKEN" # → { "items": [...next 50 rows...], "nextCursor": null } ← end of the list ``` The cursor is an opaque, signed token - never construct one by hand or try to decode it for meaning; always pass back exactly what the previous response gave you. A page always includes `nextCursor`, even on the very last page (as `null`), so "keep paging while `nextCursor` is truthy" is a correct loop condition without a separate "am I done" check. ## Upload an attachment [#upload-an-attachment] Attachments are multipart uploads to the ticket's attachment route: ```bash curl -X POST https://acme.example.com/api/tickets//attachments \ -H "Authorization: Bearer $ORBOTO_TOKEN" \ -F "file=@screenshot.png" ``` The response returns the stored file's metadata - `id`, `filename`, `contentType`, `sizeBytes`, and a `downloadUrl`. **Store the attachment id, not the download URL** - download links are short-lived; re-fetch `GET /attachments/` (or `.../base64` for inline bytes) whenever you need the file again. ## Bulk update tickets [#bulk-update-tickets] Apply one field change to many tickets in a single call instead of looping over individual updates: ```bash curl -X POST https://acme.example.com/api/projects//tickets/bulk \ -H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \ -d '{"ids":["",""],"action":"status","value":""}' ``` `action` is one of `status`, `milestone`, `assignee`, `due_date`, `version`, `priority`; `value` is the new value for that field (a status id, milestone id, user id, ISO date, version id, or priority string - `null` clears an optional field, e.g. unassigning). The response is `{"updated": N}` - the count of tickets actually changed. This needs `project:edit` on the target project, same as any other ticket mutation. For heterogeneous per-ticket changes (different fields per ticket), loop individual `PATCH /tickets/` calls instead - the bulk endpoint applies one field/value pair across the whole id list. ## Register and consume a webhook [#register-and-consume-a-webhook] Webhooks deliver workspace events (ticket created, comment posted, and more) to a URL you control, HMAC-signed so you can verify each delivery. **1. Register the webhook** on a project: ```bash curl -X POST https://acme.example.com/api/projects//webhooks \ -H "Authorization: Bearer $ORBOTO_TOKEN" -H "Content-Type: application/json" \ -d '{ "name": "ticket-sync", "url": "https://your-service.example.com/hooks/orboto", "events": ["ticket.created", "ticket.updated", "comment.created"] }' ``` This needs `project:edit` on the target project. The response includes `secret` - a signing secret **shown once, at creation**. Store it alongside the webhook id; there's no route to retrieve it again later (only rotate it, which invalidates the old one). **2. Verify each delivery.** Every request carries `X-Orboto-Event` (the event name) and `X-Orboto-Signature: sha256=`, computed as an HMAC-SHA256 of the raw request body using your webhook's secret. Verify before trusting the payload: ```js const crypto = require("crypto"); function isValidDelivery(secret, rawBody, signatureHeader) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signatureHeader), ); } ``` Use the raw, unparsed request body for the HMAC input - re-serializing parsed JSON can change byte-for-byte formatting and break the comparison. **3. Inspect available events.** `GET /admin/webhooks/events` returns every event name, a description, and an example payload - useful for building the consumer without guessing the shape. **4. Debug and replay deliveries.** Every attempt (success or failure) is logged: ```bash curl https://acme.example.com/api/webhooks//deliveries \ -H "Authorization: Bearer $ORBOTO_TOKEN" curl -X POST https://acme.example.com/api/webhooks//redeliver/ \ -H "Authorization: Bearer $ORBOTO_TOKEN" ``` Redelivery re-sends the exact original payload - handy after fixing a consumer bug without waiting for the event to happen again. Setting `payloadFormat` to `slack`, `microsoft_teams`, or `discord` instead of the default `generic` reshapes the body for that chat tool's incoming-webhook format; vendor-shaped deliveries skip the `X-Orboto-Signature` / `X-Orboto-Event` headers since those tools don't read them. ## Errors [#errors] Errors carry a stable `errorKey` (machine-readable) alongside a human-readable `error` message - branch on the key, display the message. ### Troubleshooting [#troubleshooting] | Symptom | Fix | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` on every call | Check `Authorization: Bearer ` is present and the key hasn't been revoked or expired - mint a fresh one from your profile. | | `403` on a route that worked for another project | Permissions are per-project. Confirm the API key's owner is actually a member of *this* project with the required permission, not just any project. | | Attachment upload returns `413` or `415` | `413` is over the size limit; `415` is a content type the instance doesn't accept. Check your instance's configured limits. | | Bulk update returns `updated: 0` | The `ids` you sent didn't resolve to tickets you can edit in this project - a bulk call is silently a no-op on ids it can't touch, it never partially fails the *request*, only the *effect*. | | Webhook deliveries show as failing in the delivery log | Your endpoint must return a 2xx within the delivery timeout. Check the logged response status/body per delivery, fix the consumer, then use `redeliver` to replay the specific failed event instead of waiting for it to recur. | | Signature verification always fails | Compare against the **raw** request body, not a re-stringified copy of the parsed JSON - whitespace and key order differences change the HMAC. | # API & CLI (/api-cli) orboto is API-first - the web app, the CLI, the MCP server and every integration all speak the same REST API with the same permissions. ## The REST API [#the-rest-api] * Interactive OpenAPI reference: `https:///docs` on any running instance - every route, schema and response, generated from the live server. * Authenticate with an API key (`orb_*`, minted in your profile or by an admin for bot accounts) sent as `Authorization: Bearer `. * List endpoints use cursor pagination: pass `?cursor=&limit=50`, read `items` + `nextCursor` from the response. ## For LLMs [#for-llms] These docs are machine-readable: [`/llms.txt`](/llms.txt) is the index, [`/llms-full.txt`](/llms-full.txt) the full text, and every page has a raw Markdown twin via the page-actions menu (Copy as Markdown, Open in ChatGPT/Claude). # OQL query language (/api-cli/oql) OQL is a typed, ACL-aware query language for tickets. It's the canonical way to express filter combinations that the per-entity list endpoints (`/projects/:id/tickets?status=…&assignee=…`) can't reach without growing yet another querystring parameter. OQL also accepts JQL syntax through a thin compatibility adapter - paste a JQL query, get the same result. *** ## At a glance [#at-a-glance] ``` project = ACME AND assignee = currentUser() AND statusCategory != done ORDER BY priority DESC, dueDate ASC LIMIT 25 ``` * **Endpoint:** `POST /query` (cursor-paginated, rate-limited at 60/min/user). * **Surface:** REST endpoint, MCP tool `orboto_query`, CLI shortcut `orboto query ""`, ⌘K palette OQL toggle. * **Authorisation:** every query starts from a project-membership join + private-ticket guard. External users get `is_private = false` hard-pinned regardless of how the OQL is shaped. *** ## Writing your first query [#writing-your-first-query] If you've never written OQL before, build it up one clause at a time rather than reading the grammar cold. Every example below is real and runnable through any [surface](#surfaces). **Start with one condition.** A query is just `field operator value`: ``` assignee = currentUser() ``` This alone returns every ticket assigned to you, across every project you're a member of - no `LIMIT`, no `ORDER BY`, nothing else required. `currentUser()` is a function call that resolves to your own user id at query time, so the same query text means "my tickets" for whoever runs it. **Add a second condition with `AND`.** Combine conditions the way you'd expect from any query language: ``` assignee = currentUser() AND statusCategory != done ``` Now it's your tickets that aren't finished yet - `!=` excludes rather than includes, and `statusCategory` is the four-value workflow bucket (`todo` / `in_progress` / `in_review` / `done`) rather than a project-specific status name, so this works the same regardless of how any individual project labels its statuses. **Sort the results.** `ORDER BY` goes after every filter condition, and takes one or more fields with an optional direction (`ASC` is the default when you omit it): ``` assignee = currentUser() AND statusCategory != done ORDER BY priority DESC, dueDate ASC ``` This reads as "my open work, most urgent priority first, and for ties on priority, whichever is due soonest." **Cap how many rows come back.** `LIMIT` goes last: ``` assignee = currentUser() AND statusCategory != done ORDER BY priority DESC, dueDate ASC LIMIT 10 ``` **Scope to a project, and search text.** Add `project = ` to narrow to one project, and `~` for a case-insensitive substring match on a text field: ``` project = ACME AND title ~ "timeout" AND statusCategory != done ``` **Check for something being missing.** `IS EMPTY` / `IS NOT EMPTY` on a multi-valued field, and `IS NULL` / `IS NOT NULL` on a scalar one: ``` assignee IS EMPTY AND statusCategory = todo ``` That's the whole shape of the language: pick fields from the [field reference](#field-reference) below, combine them with `AND` / `OR` / `NOT` and parentheses for grouping, optionally sort and limit. Everything past this point is reference material for the specific fields, functions, and edge cases available to you. *** ## Grammar [#grammar] Formal PEG grammar lives in `apps/api/src/services/oql/grammar.peggy`. Informally: ``` query = expression (ORDER BY orderList)? (LIMIT integer)? expression = orExpr orExpr = andExpr (OR andExpr)* andExpr = notExpr (AND notExpr)* notExpr = NOT? predicate predicate = "(" expression ")" | field operator value | field "IN" "(" valueList ")" | field "IS" ("NOT")? ("NULL" | "EMPTY") operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "!~" value = stringLit | numberLit | dateLit | functionCall | identifier functionCall = "currentUser()" | "now()" | "startOfWeek()" | "endOfWeek()" | "startOfMonth()" | "endOfMonth()" | "daysAgo(n)" ``` * Keywords (`AND`, `OR`, `NOT`, `IN`, `IS`, `NULL`, `EMPTY`, `ORDER`, `BY`, `LIMIT`, `ASC`, `DESC`) are case-insensitive and word-bounded - `andrew` is an identifier, not `AND` + `rew`. * Strings: `"double"` or `'single'` quotes. Backslash-escapes `\"` and `\'` work. * Identifiers: `[a-zA-Z0-9_][a-zA-Z0-9_-]*` - the trailing dashes let `ACME-42` parse as a bareword, and the leading-digit class lets digit-leading project keys like `10M` or `3D` parse without quoting. Pure-digit tokens still parse as numbers because `NumberLit` is tried first in value position. * Dates: ISO format `"YYYY-MM-DD"` as a quoted string. Function calls return either ISO timestamps (`now()`, `daysAgo(n)`) or strings (`currentUser()` → user UUID). * Empty input is valid and matches every visible row (subject to ACL). *** ## Field reference [#field-reference] Grouped by what the field describes, so you can jump straight to the category you need. Every field accepts the operators listed; anything else returns 400 with `errorKey: errors.oql.unknown_field`. ### Identity [#identity] | Field | Type | Operators | Notes | | -------------- | ------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `key` | string | `= != < <= > >= ~ !~` | Ticket key, e.g. `ACME-42`. | | `ticketNumber` | number | `= != < <= > >= ~ !~` | Integer half of `ticketKey` (ACME-42 → 42). Use this - not `key` - when sorting numerically, otherwise `ACME-10` lands before `ACME-2`. | | `title` | string | `= != ~ !~` | Ticket title. `~` is case-insensitive substring match (ILIKE). | | `description` | string | `= != ~ !~` | Ticket description body. `COALESCE`d to empty string so `~` matches NULL rows correctly. | ### Status, type, priority [#status-type-priority] | Field | Type | Operators | Notes | | -------------------------------- | ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `status` | enum | `= != ~ !~` | Legacy lifecycle enum (`TODO` / `IN_PROGRESS` / `IN_REVIEW` / `DONE` / `WONT_FIX`). Backed by `tickets.status`, kept in sync with the FK status's category. | | `statusName` | string | `= != ~ !~` | The display name of the ticket's current `ticket_status` row (e.g. `"In Progress"`). Use this for projects with custom workflow names. | | `statusCategory` | enum | `= !=` | Workflow bucket: `todo` / `in_progress` / `in_review` / `done`. | | `priority` | enum | `= !=` | `blocker` / `high` / `normal` / `low` / `trivial`. | | `priorityLevel`, `severityLevel` | number | `= != < <= > >= ~ !~` | Numeric mapping of `priority`: blocker=5, high=4, normal=3, low=2, trivial=1. Higher = more important. Use for `priorityLevel >= 4` ("high or blocker") and ORDER BY. Both names accepted. | | `type` | enum | `= !=` | `task` / `bug` / `story` / `epic`. | ### People [#people] | Field | Type | Operators | Notes | | ----------------------- | ------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `assignee` | identifier (multi) | `=` (membership) · `IN (…)` · `IS [NOT] EMPTY` | Resolves by UUID, exact email, OR case-insensitive full name. `IS EMPTY` = unassigned. | | `reporter`, `createdBy` | identifier | `= !=` | `tickets.created_by`. Both names accepted. Resolves by UUID, exact email, OR case-insensitive full name. A value matching no user returns an empty result set (never an error); ambiguous names match any user with that name. | | `reporterType` | enum | `= !=` | `guest` / `internal` - is the ticket's creator an external (guest) account? `reporterType = guest` pulls up everything filed by guests without listing each guest's email. | | `assigneeType` | enum | `= !=` | `guest` / `internal` - `guest` when the ticket has at least one external assignee; `internal` is the exact complement (all-internal or unassigned). | ### Labels, dependencies, git [#labels-dependencies-git] | Field | Type | Operators | Notes | | ------------------------ | ------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `labels` | identifier (multi) | `=` (membership) · `IN (…)` · `IS [NOT] EMPTY` | Match by label name. | | `dependsOn`, `blockedBy` | identifier (multi) | `=` (membership) · `IN (…)` · `IS [NOT] EMPTY` | Match the depended-on ticket by UUID or ticketKey. `IS EMPTY` = no dependencies. Both names mean the same direction (A depends on B = A is blocked by B). | | `hasGitActivity` | boolean | `= !=` | True when the ticket has any linked commit, PR, or issue event in `git_activities`. | | `waitingForGitIngestion` | boolean | `= !=` | True when the ticket is `in_review`, has zero `git_activities` rows, AND the project has an active git connection - a stalled-ingestion signal distinct from a ticket that's simply not linked to any commit. | ### Organization [#organization] | Field | Type | Operators | Notes | | ----------- | ------- | ----------------- | --------------------------------------------------------------------------------- | | `milestone` | string | `= !=` · `IN (…)` | Milestone key (`ACME-M3`), display name, or UUID. Use the key when names collide. | | `version` | string | `= !=` | Version display name. | | `project` | string | `= !=` | Project key (e.g. `ACME`) or display name. | | `parentKey` | string | `= !=` | Parent ticket's key - useful for walking an epic into its children. | | `isPrivate` | boolean | `= !=` | `true` / `false`. | ### Dates [#dates] | Field | Type | Operators | Notes | | ------------------------------------------------------------ | ---- | --------------------- | ------------------------------------------------------ | | `dueDate`, `startDate`, `closedAt`, `createdAt`, `updatedAt` | date | `= != < <= > >= ~ !~` | ISO date strings or function values like `daysAgo(7)`. | ### Effort and time [#effort-and-time] | Field | Type | Operators | Notes | | ------------------------- | ------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `estimatedTimeMinutes` | number | `= != < <= > >= ~ !~` | Integer minutes. | | `loggedMinutes` | number | `= != < <= > >= ~ !~` | Computed via correlated SUM over `time_entries`; expensive on huge time tables. | | `progress`, `donePercent` | number | `= != < <= > >= ~ !~` | `loggedMinutes / estimatedTimeMinutes * 100`. NULL when estimate is missing or zero - `progress IS NULL` finds unestimated tickets. Uncapped: values > 100 mean "over budget". Both names accepted. | *** ## Function reference [#function-reference] | Function | Returns | Notes | | -------------------------------- | ------------- | ------------------------------ | | `currentUser()` | string (UUID) | The requester's user id. | | `now()` | ISO timestamp | Wall-clock at translate time. | | `startOfWeek()`, `endOfWeek()` | ISO timestamp | Monday-start week, UTC. | | `startOfMonth()`, `endOfMonth()` | ISO timestamp | UTC month boundary. | | `daysAgo(n)` | ISO timestamp | `n` whole days before `now()`. | The function whitelist is closed - anything else (`pg_sleep()`, `version()`, `current_user`, …) is rejected at translate time. *** ## Operators [#operators] * `=`, `!=` - equality. For multi-valued fields (`assignee`, `labels`) this is membership. * `<`, `<=`, `>`, `>=` - ordering on numbers and dates. * `~`, `!~` - case-insensitive ILIKE. Pattern is automatically wrapped in `%…%`. * `IN (a, b, c)` - equality against any of the listed values. * `IS NULL` / `IS NOT NULL` - null checks on scalar fields. * `IS EMPTY` / `IS NOT EMPTY` - multi-valued field has zero / non-zero rows. *** ## ORDER BY + LIMIT [#order-by--limit] ``` … ORDER BY priority DESC, dueDate ASC … LIMIT 25 ``` Multiple sort keys allowed. Direction defaults to `ASC` when omitted. The route appends `tickets.id` as a tie-breaker so cursor walks don't stutter. `LIMIT` caps the page size at the source. The route also clamps to 200 - anything larger is silently capped. *** ## Cookbook [#cookbook] ``` # My open work, blockers first assignee = currentUser() AND statusCategory != done ORDER BY priority DESC, dueDate ASC # Tickets I touched this week assignee = currentUser() AND updatedAt >= daysAgo(7) ORDER BY updatedAt DESC # Anything blocking ORB this week project = ACME AND priority IN (blocker, high) AND dueDate <= endOfWeek() # Unowned bugs in the backlog labels = bug AND assignee IS EMPTY AND statusCategory = todo # At-risk for budget - logged exceeds estimate loggedMinutes > estimatedTimeMinutes AND statusCategory != done # Children of an epic parentKey = ACME-200 ORDER BY ticketKey ASC # Tickets shipped in the last sprint statusCategory = done AND closedAt >= startOfMonth() AND closedAt < endOfMonth() # Stale review queue - sitting in review for 3+ days statusCategory = in_review AND updatedAt <= daysAgo(3) # All my assigned bugs in the current quarter assignee = currentUser() AND labels = bug AND createdAt >= startOfMonth() # Free-text contains in a ticket key prefix key ~ "ACME-2" # Everything filed by guests across my projects reporterType = guest ORDER BY createdAt DESC # Open tickets that have a guest assignee assigneeType = guest AND statusCategory != done # All guest-related tickets (reported by OR assigned to a guest) reporterType = guest OR assigneeType = guest ``` *** ## JQL compatibility [#jql-compatibility] Pass `syntax: 'jql'` (or `--syntax=jql` from the CLI) to accept JQL-flavoured input. The adapter pre-rejects unsupported tokens then translates the survivor through the OQL parser with field/value renames applied. ### Field aliases (JQL → OQL) [#field-aliases-jql--oql] | JQL | OQL | | ------------ | ---------------- | | `due` | `dueDate` | | `created` | `createdAt` | | `updated` | `updatedAt` | | `resolved` | `closedAt` | | `resolution` | `statusCategory` | | `sprint` | `milestone` | | `issuetype` | `type` | ### Value aliases on `resolution` / `statusCategory` [#value-aliases-on-resolution--statuscategory] | JQL value | OQL value | | --------------- | ------------- | | `Done` | `done` | | `Unresolved` | `todo` | | `"In Progress"` | `in_progress` | ### Unsupported features (and what to do instead) [#unsupported-features-and-what-to-do-instead] | JQL feature | Suggested OQL | | ------------------ | ----------------------------------------------------------------- | | `WAS` | `updatedAt >= daysAgo(30)` (we don't track historic field values) | | `CHANGED` | `updatedAt >= daysAgo(7)` | | `issueHistory()` | None; the audit log surface is the closest analogue. | | `votes` | `priority` or labels for marking importance. | | `workRatio` | `loggedMinutes > estimatedTimeMinutes`. | | `attachments` | Not queryable; surfaced on the ticket detail. | | `originalEstimate` | `estimatedTimeMinutes` (orboto only stores the current estimate). | | `cf[…]` | Not supported - orboto has no custom-field schema. | Each unsupported token produces a 400 with `errorKey: errors.jql.unsupported_feature` carrying a `suggestion` field the UI surfaces verbatim. *** ## Endpoint [#endpoint] ``` POST /query Authorization: Bearer Content-Type: application/json { "oql": "project = ACME AND assignee = currentUser()", "syntax": "oql", // or "jql" - defaults to "oql" "cursor": "...", // opaque, from a previous response.nextCursor "limit": 25 // 1-200, default 25 } ``` Response: ```json { "items": [Ticket, ...], "nextCursor": "opaque-token-or-null", "queryPlan": { "sql": "...", "params": [], "ast": {...} } // ?explain=true, super-admin only } ``` ### Errors [#errors] All 4xx errors follow the `ErrorResponseSchema` shape (`error`, `errorKey`, `errorParams`): | Status | errorKey | When | | ------ | --------------------------------- | ------------------------------------------------------------------------------------------ | | 400 | `errors.oql.parse` | Syntax error. `errorParams` carries `line`, `column`, `expected`, `message`. | | 400 | `errors.oql.unknown_field` | Field not in the whitelist. | | 400 | `errors.oql.unknown_function` | Function not in the whitelist. | | 400 | `errors.oql.unsupported_operator` | Operator can't be applied to that field. | | 400 | `errors.oql.invalid_value` | Type coercion failed (e.g. malformed date, enum value not in allow-list). | | 400 | `errors.oql.wrong_arity` | Function called with the wrong number of arguments. | | 400 | `errors.jql.unsupported_feature` | JQL adapter rejected an unsupported token. `errorParams` carries `token` + a `suggestion`. | | 401 | `errors.common.unauthorized` | Missing / invalid bearer token. | | 429 | `errors.common.rate_limited` | More than 60 calls / minute / user. | ### `?explain=true` [#explaintrue] Super-admin only. Adds `queryPlan: { sql, params, ast }` to the response so an operator can debug "why does my OQL return 0 rows" without database access. Non-admins receive the same 200 response without the field - no leak that the param exists. *** ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `errors.oql.unknown_field` on a field you're sure exists | Check spelling and case - fields are case-sensitive (`statusCategory`, not `StatusCategory`). Confirm it's in the [field reference](#field-reference) above; custom fields aren't supported (see [Roadmap](#roadmap)). | | Query parses but returns 0 rows unexpectedly | Authorisation is applied before your filter (see below) - you only ever see tickets your account can already see. As a super-admin, add `?explain=true` to inspect the generated SQL. | | `ACME-10` sorts before `ACME-2` | Sort on `ticketNumber`, not `key` - `key` sorts as text. | | A date comparison silently matches nothing | Dates must be quoted ISO strings (`"2026-01-15"`) or a function call (`daysAgo(7)`) - a bare unquoted date is parsed as an identifier and fails field lookup. | | JQL input rejected with `errors.jql.unsupported_feature` | The adapter has a closed alias list ([field aliases](#field-aliases-jql-oql), [unsupported features](#unsupported-features-and-what-to-do-instead)). The error's `suggestion` field names the OQL equivalent to use directly. | | `errors.common.rate_limited` | More than 60 calls/minute for the user. Batch reads with a wider `LIMIT` and cursor pagination instead of polling in a tight loop. | *** ## Authorisation [#authorisation] The translator embeds the auth pipeline into the WHERE clause as the FIRST predicate, in this exact order: 1. **Project membership** - `EXISTS project_members WHERE user_id = :requester`. Super-admin bypass collapses to `TRUE`. 2. **Private-ticket visibility** - `is_private = false OR EXISTS ticket_access_acl OR has-global ticket:view_private`. 3. **External-user hard pin** - when the user row has `is_external = true`, `is_private = false` is appended LAST. No OQL clause can widen the filter past this guard. Every query - `*` SELECT, `IS NULL`, OR-of-everything - runs with this predicate AND-ed in. The translator's security tests assert this contract. *** ## Surfaces [#surfaces] | Surface | How | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **REST** | `POST /query` (this doc). | | **CLI shortcut** | `orboto query "" [--syntax=oql\|jql] [--limit=25] [--cursor=...] [--explain]` - see the [CLI daily workflow](/api-cli/cli-usage#milestones-docs-and-search). | | **MCP tool** | `orboto_query` - same input shape, structuredContent envelope mirrors the REST response. | | **⌘K palette** | OQL toggle next to the AI toggle. Errors render inline with line/column. "Save as bookmark" persists the OQL via `saved_searches.oql`. | | **Saved searches** | `saved_searches.query_type ENUM ('legacy', 'oql', 'jql')` + `saved_searches.oql TEXT`. New bookmarks are oql/jql; old structured bookmarks keep working under `legacy`. | | **AI search bridge** | `/search/nl` returns the equivalent OQL string in `response.oql` - the palette shows it under the chip row with an "Edit in OQL" button that pre-fills the OQL input. | {/* screenshot: the palette's OQL toggle with a query typed in and results below */} *** ## Performance notes [#performance-notes] * The translator is pure-function and \< 1 ms per call. The compiled peggy parser caches at module-load. * `loggedMinutes` uses a correlated SUM over `time_entries`; on workspaces with millions of time entries this slows queries that filter on it. A materialised view is the planned escape hatch when the slow-query threshold trips. * ORDER BY on text fields (`title`, `key` via `~`) doesn't use the search-vector index - keep complex text searches on the dedicated `/search` endpoint and use OQL for the structured filter combinations. * The cursor is `(updated_at, id)` tuple-comparison so default-ordered queries paginate efficiently. Custom ORDER BY queries pay an in-memory sort if the order doesn't match an existing index. *** ## Roadmap [#roadmap] Out of scope for v1 (intentionally): * Multi-entity queries (`FROM tickets, comments`). * Aggregation (`COUNT`, `SUM`, `GROUP BY`) - the analytics endpoints already cover those. * Update queries (`UPDATE tickets SET status = ...`) - too dangerous for a public DSL. * Custom-field schema - not in the data model. * Free-text body search - stays on `/search`. * Query history / performance insights. If any of these become hard requirements, let us know. # Concepts and glossary (/getting-started/concepts) Welcome to orboto - a place for teams to plan work, track it, and talk about it. This guide is written for the people who *use* orboto every day: it explains, task by task, how to get things done. You do not need to be technical to follow it. If you are brand new, start with [Getting started](/getting-started) and [Your first project](/getting-started/first-project). Otherwise, jump straight to the chapter you need, or use this page as a reference glossary whenever a term is unfamiliar. > **A note on what you can see.** What appears in your sidebar and on your screens depends on your **role** and **permissions**. If this guide describes a button or page you cannot find, your workspace administrator has not granted you access to it. That is normal - it is not a fault. Administrator-only features (managing users, single sign-on, imports, backups, and so on) are covered separately; this guide points to that material where relevant rather than duplicating it. ## Contents [#contents] 1. [Getting started](/getting-started) - signing in, the layout of the screen, themes, language, and keyboard shortcuts. 2. [Your first project](/getting-started/first-project) - a hands-on walkthrough: create a project, add a ticket, move it across the board. 3. [Your dashboard](/user-guide/everyday/dashboard) - your personal home page and its widgets. 4. [Projects and tickets](/user-guide/work/projects-and-tickets) - the core: projects, the five board views, and everything a ticket can do. 5. [Search](/user-guide/knowledge/search) - global search, the query language, saved searches, and AI search. 6. [Docs and wiki](/user-guide/knowledge/docs-and-wiki) - spaces, pages, smart links, sharing, and asking questions of your docs. 7. [Time tracking](/user-guide/planning/time-tracking) - the stopwatch, time calendar, timesheets, absences, and capacity. 8. [Planning and alerts](/user-guide/planning/planning-and-alerts) - milestones, versions, project templates, and alert rules. 9. [Analytics](/user-guide/analytics) - burndown, velocity, flow metrics, forecasting, and earned value. 10. [Notifications](/user-guide/everyday/notifications) - the inbox, preferences, and chat channels. 11. [Account and security](/user-guide/everyday/account-and-security) - your profile, password, two-factor authentication, passkeys, and sessions. 12. [AI assistant](/user-guide/ai-assistant) - the built-in chat assistant and the AI features across the product. 13. [Customers and guests](/user-guide/customers-and-guests) - external organisations and the guest experience. 14. [Integrations](/integrations) - connecting AI clients, code repositories, and chat tools, plus admin pointers. 15. [Requirements documents and reports](/user-guide/knowledge/requirements-documents) - making a project ready for a requirements specification (Pflichtenheft) and a customer report, and which project data fills which chapter. ## How the pieces fit together [#how-the-pieces-fit-together] Before the glossary, the short version of how everything nests: * **Workspace** - your whole orboto instance, shared by your team. * **Project** (key `ACME`) - a container for related work. * **Milestone** - a checkpoint (a phase, a sprint) that groups tickets toward a date. * **Ticket** (key `ACME-42`) - one unit of work, living in exactly one project and optionally one milestone. * **Comments**, **checklists**, **attachments**, and **time entries** all live on a ticket. * **Doc space** - a folder of documentation pages that can reference the project's tickets via smart links. Everything else in the glossary below is a property of, or an action on, one of these. ## Glossary [#glossary] ### Core work items [#core-work-items] * **Project** - a container for related work, with a short **key** (for example `ACME`) that prefixes its tickets. * **Ticket** - one unit of work: a task, a bug, a story, or an epic. Identified by a key like `ACME-42`. * **Task** - a ticket type for a standalone unit of work with no further breakdown. * **Bug** - a ticket type for something that is broken and needs fixing. * **Story** - a ticket type for a piece of user-facing functionality, often broken down into smaller tasks. * **Epic** - a ticket type for a large piece of work that groups several smaller tickets under it as children. * **Status** - the stage a ticket is in (for example To Do, In Progress, Done); the columns on the Kanban board. Configurable per project. * **Priority** - how urgent a ticket is (for example blocker, high, normal, low, trivial). * **Label** - a coloured tag you attach to tickets to categorise them. * **Assignee** - a person responsible for a ticket. A ticket can have several. * **Checklist** - a list of smaller steps on a ticket; an item can link to another ticket so it ticks itself off when that ticket closes. * **Parent / sub-ticket** - a ticket can have a parent and children, for breaking an epic or story into smaller pieces. * **Dependency** - a relationship where one ticket **blocks** or is **blocked by** another. * **RACI** - a way to record who is **Responsible** (does the work), **Accountable** (owns the outcome), **Consulted** (gives input), and **Informed** (kept in the loop). ### Board views [#board-views] * **Kanban** - a board of cards in status columns that you drag work across. * **List** - a compact, sortable, groupable table of tickets. * **Gantt** - a timeline view placing tickets by their start and due dates, with dependency links drawn between bars. * **Network** - a diagram of tickets connected by their dependencies - the fastest way to spot a blocker chain. ### Planning [#planning] * **Milestone** - a meaningful checkpoint in a project (a phase, a sprint, a deliverable) that groups tickets toward a shared goal and date. Key form: `ACME-M3`. * **Version** - a planned release of your product, with a target release date, bundling the work shipped together. * **Template** - a reusable project or milestone structure you can apply to a new project. * **Alert** - an automatic warning that fires when something needs attention (a deadline at risk, a budget nearing its limit). ### Time and capacity [#time-and-capacity] * **Timesheet** - your logged time for a period, gathered for approval. * **Absence** - a period of time off (holiday, sick leave, and so on), counted in working days. * **Capacity** - the available working time a person has, compared against assigned and logged time. ### Analytics [#analytics] * **Velocity** - how much work the team completes per period; used to plan future work. * **Cycle time** - how long a ticket takes from start to done. * **Burndown** - a chart of remaining work over time against an ideal line. * **Cumulative flow diagram** - a chart of how many tickets sit in each status over time, showing where work piles up. * **Delivery forecast** - a projected completion range and confidence level for a set of tickets, based on your team's actual pace. * **Earned value (EVM)** - a standard method for checking whether progress matches the schedule and spend it should have bought. ### Knowledge [#knowledge] * **Space** - a folder of related documentation pages. * **Doc** - a single documentation page, written with the same rich-text editor as tickets. * **Smart link** - a live link (typed with `[[`) to a ticket, doc, project, or milestone that always shows the target's current title. * **Ask AI** - a panel that answers a question about your docs, with links back to the source pages it used. ### Notifications [#notifications] * **Notification** - a message telling you something happened to your work, delivered in-app, by email, or to a chat channel. * **Notification channel** - an external destination (such as a chat tool) subscribed to receive notifications, configured per workspace. ### Security and access [#security-and-access] * **Role** - the set of permissions assigned to a member; what you can see and do is derived from it. * **Permission** - a single granted capability (for example, creating projects or managing users). * **Two-factor authentication (2FA)** - a second sign-in step beyond your password, using an authenticator app or a passkey. * **Passkey** - a sign-in method using your device's fingerprint, face, or PIN instead of a code. * **Single sign-on (SSO)** - signing in through your organisation's central identity provider. * **Guest / external user** - someone outside your team given narrow access to specific shared content. ### AI and integrations [#ai-and-integrations] * **AI assistant** - the in-app chat assistant that can answer questions and take actions on your behalf, with your confirmation. * **MCP (Model Context Protocol)** - the standard that lets AI clients such as Claude Desktop or Cursor connect to orboto. ## Troubleshooting [#troubleshooting] **A term in this guide doesn't match what I see in the app - for example, my board has no "To Do" column.** Cause: statuses and workflows are configurable per project, so an administrator may have renamed, reordered, or removed a default one. Fix: check the project's **Settings → Statuses** tab, or ask whoever manages the project. # Create your first project and ticket (/getting-started/first-project) This page is the fastest way to get a feel for orboto: create a project, add a ticket to it, and move that ticket across the board - with every field along the way explained, not just clicked through. Fifteen minutes, start to finish. You need to be signed in already - see [Getting started](/getting-started) if you have not signed in yet. ## 1. Create a project [#1-create-a-project] A **project** is the container everything else lives in: tickets, milestones, docs spaces, and members all belong to exactly one project (unless a workspace-wide feature explicitly says otherwise). 1. Select **Projects** in the sidebar. 2. Select **New project**. 3. Fill in the form: * **Name** - the full, human-readable name (for example "Website Relaunch"). This is what shows everywhere in menus and titles. * **Key** - a short project code, 2 to 10 letters or digits (for example `WEB`). Every ticket you create afterwards is numbered under this key (`WEB-1`, `WEB-2`, ...), so pick something short and permanent - you can rename the project later, but changing its key is disruptive because it re-numbers every reference to every ticket. Lowercase is fine to type; it is stored as uppercase automatically. If the key you type is already used by another project in your workspace, the form tells you before you can save. * **Description** (optional) - shown on the project card and its overview; use it to say what the project is for. * **Customer** (optional) - link the project to a customer record if your workspace tracks external customers. This is what makes the project eligible for [guest access](/user-guide/customers-and-guests) later. 4. Select **Save**. {/* screenshot: the new-project form with name, key and description fields */} You land on the new project, currently empty, showing the Kanban board. You can refine members, statuses, and labels later in its settings - see [Project settings](/user-guide/work/projects-and-tickets#project-settings-a-quick-tour). None of that is required to start working; a freshly created project is automatically seeded with a usable set of default statuses (To Do, In Progress, In Review, Done), so the board works immediately. > **Don't see New project?** Project creation is a permission your workspace administrator grants, separately from being able to work inside projects you're already a member of. Ask them to either grant it to you or create the first project and add you as a member - everything below still applies once you have a project open, whoever created it. ## 2. Create a ticket [#2-create-a-ticket] A **ticket** is one unit of work, always living inside exactly one project. 1. In your new project, select **New ticket**. 2. Fill in the form: * **Title** - the only field you must fill in. Keep it short and specific; you write the details in the description below. * **Type** - `task`, `bug`, `story`, or `epic`. This only changes the icon shown on the card and what you can filter by later - it doesn't restrict what you can do with the ticket. Pick `epic` if this piece of work is going to be broken down into several smaller tickets under it; pick `task` for anything else if you're not sure. * **Priority** - `blocker`, `high`, `normal`, `low`, or `trivial`. Drives sort order, filtering, and (if your admin has configured them) SLA response-time targets and alerts. * **Milestone** / **Version** (optional) - only shown once you've created at least one in this project; both default to empty. Leave them unset for now - see [Planning and alerts](/user-guide/planning/planning-and-alerts) when you're ready to schedule work. * **Estimate** (optional) - how long you expect the work to take. Feeds the Gantt view and capacity comparisons once you start using those. 3. Add a description with the rich-text editor if you want more detail - it supports formatting, pasted images, and `@mentions`, exactly like a comment. 4. Select **Save**. {/* screenshot: the new-ticket form filled in with a title and a type selected */} The ticket now shows on the board as `WEB-1` (or whichever key your project uses) as a card in the first status column - usually **To Do**. Selecting the card opens the full **ticket detail** panel, where every field above is editable again, plus the comment thread, checklists, attachments, and the rest of what a ticket can carry - covered in full in [Projects and tickets](/user-guide/work/projects-and-tickets). > **Faster capture.** Switch the project to **List** view. Under any milestone group (or "No milestone"), a dashed **+ Add ticket** row expands into a compact inline form - type, title, due date, and assignee, no description or priority. Press Enter to create it and the row stays open with a cleared title, so you can bang out several tickets in a row without reopening the full dialog each time. Open any of them afterwards to fill in the rest. ## 3. Move it across the board [#3-move-it-across-the-board] Open the **Kanban** view (the default) and drag the ticket's card from **To Do** into **In Progress**, and eventually into **Done**. Dropping a card into a different column immediately changes the ticket's **status** field - there is no separate save step, and the change is visible to every other member of the project in real time. You can achieve the exact same result without dragging: open the ticket and change its **Status** field directly from the sidebar. Use whichever is faster for you - dragging one card on the board, or changing status from inside a ticket you already have open. {/* screenshot: dragging a ticket card from To Do into In Progress on the Kanban board */} ## 4. Comment and mention a teammate [#4-comment-and-mention-a-teammate] Open the ticket and scroll to the **comment thread** at the bottom. Type a comment, format it with the toolbar if you like, and type `@` followed by a name to mention a teammate - typing `@` opens an autocomplete list of project members, and selecting one inserts the mention and, once you post the comment, sends that person a notification. This is the normal way work gets discussed in orboto instead of a side channel: the discussion stays attached to the ticket forever, instead of scattered across chat history. ## 5. Optional: log time against it [#5-optional-log-time-against-it] If your workspace tracks time, open the ticket and look for the timer control in its sidebar - start it while you work and stop it when you're done, or add a manual time entry with a specific date and duration if you're logging after the fact. See [Time tracking](/user-guide/planning/time-tracking) for the full picture - the stopwatch, the time calendar, and timesheets. ## Where to go next [#where-to-go-next] * The full picture of projects, views, and everything a ticket can do: [Projects and tickets](/user-guide/work/projects-and-tickets) * Every term you just met, explained: [Concepts and glossary](/getting-started/concepts) * Find anything fast across your whole workspace: [Search](/user-guide/knowledge/search) * Write documentation alongside your work: [Docs and wiki](/user-guide/knowledge/docs-and-wiki) ## Troubleshooting [#troubleshooting] **The status column I want isn't on the board.** Cause: your administrator can mark some statuses as hidden on the board by default, to keep it tidy for everyday use. Fix: look for a **Show hidden columns** toggle above the board and turn it on - it also shows how many tickets are tucked away in hidden columns. **I created a ticket but can no longer find it in the list.** Cause: the filter bar above the board keeps whatever filter you last set (type, priority, milestone, and so on), and your new ticket may not match it - a common trap is a stale milestone filter after you leave the milestone field unset. Fix: select **Clear** on the filter bar to reset every filter at once. **The Milestone or Version dropdown is empty.** Cause: nothing has been created yet in this project - both are optional and set up per project, not pre-populated. Fix: leave it unset and create milestones/versions later in project settings, or skip straight to [Planning and alerts](/user-guide/planning/planning-and-alerts). **The project key I typed was rejected.** Cause: keys must be 2 to 10 letters or digits, and must be unique within your workspace - another project may already hold it. Fix: try a different, still-short key; the form tells you immediately if there's a collision. # Getting started (/getting-started) This chapter gets you from "I have a link to orboto" to "I know my way around the screen". If you have never used orboto before, read this first, then jump to whichever chapter matches what you want to do. ## What orboto is [#what-orboto-is] orboto is a place for teams to plan work, track it, and talk about it. Work is organised into **projects**. Inside a project you create **tickets** - a ticket is a single unit of work such as a task, a bug, a larger story, or an **epic** (a big piece of work that groups several smaller tickets). You move tickets across a board as the work progresses, discuss them in comments, attach files, log the time you spend, and watch progress on charts. Everything you can reach depends on your **role** and **permissions**. If a link or button described in this guide is not visible to you, your workspace administrator has not granted you access to that area - that is expected, not a bug. ## Signing in [#signing-in] {/* screenshot: the sign-in screen with email, password and the single-sign-on button */} 1. Open the workspace address your administrator gave you. 2. Enter your email and password, then select **Sign in**. 3. If your workspace uses **single sign-on** (SSO - logging in through your company's central identity provider, such as your Google or Microsoft work account), select the SSO button instead of typing a password, and complete the login in the window that opens. ### If you are asked for a second step [#if-you-are-asked-for-a-second-step] If your account has **two-factor authentication** turned on (an extra security step beyond your password), after your password you are asked for one more thing: * **A code** from your authenticator app - open the app on your phone and type the six-digit code. * **A passkey** - your device prompts you for your fingerprint, face, or device PIN. * **A recovery code** - if you cannot reach your phone, select the backup-code option and enter one of the one-time recovery codes you saved when you set two-factor up. Setting up two-factor authentication for your own account is covered in [Account and security](/user-guide/everyday/account-and-security). ### Accepting an invitation [#accepting-an-invitation] If you were invited to the workspace by email: 1. Open the invitation email and select the join link. 2. Set your name and a password (or continue with SSO if offered). 3. You land directly in the workspace. ### Resetting a forgotten password [#resetting-a-forgotten-password] 1. On the sign-in screen, select **Forgot password**. 2. Enter your email. If an account exists, you receive a reset link by email. 3. Open the link and choose a new password. ## The workspace at a glance [#the-workspace-at-a-glance] {/* screenshot: the full app shell - left sidebar, top bar with search, and main content area */} Once you are in, the screen has three parts: * **The left sidebar** is your main navigation. It lists the areas you have access to: Dashboard, Projects, Customers, Docs, Time, Capacity, Templates, Alerts, and (for administrators) Admin. You can collapse the sidebar to icons with the small arrow at the top of it, and it collapses automatically on narrow screens. * **The top bar** holds the global **Search** button in the centre, and on the right: your running **timer**, the **AI assistant** button (if enabled), a **language** selector, the **notification bell**, and a **settings** gear for notification preferences. * **The main area** shows whatever you selected in the sidebar. At the very bottom of the sidebar is your **profile** area - your avatar, name, and email. Select it to open your profile settings. Next to it is the **sign out** button. ### Choosing light or dark mode [#choosing-light-or-dark-mode] Near the bottom of the expanded sidebar is a small three-button switch: **Light**, **Dark**, and **System** (System follows whatever your operating system is set to). Your choice is remembered on this device and synced to your account. {/* screenshot: the light / dark / system theme switch in the sidebar */} ### Changing the language [#changing-the-language] Use the language selector in the top bar to switch the interface language. The change applies immediately. ### Setting your presence [#setting-your-presence] If your workspace has presence turned on, a small coloured dot sits on your avatar. Select the dot to switch between **Online** and **Invisible**. Invisible hides your active status from teammates without signing you out. ## Keyboard shortcuts [#keyboard-shortcuts] orboto has a few global shortcuts: * **Cmd/Ctrl + K** - open global search. * **Cmd/Ctrl + J** - open the AI assistant (when enabled for you). * **?** - show the list of shortcuts. * **Esc** - close the current dialog. {/* screenshot: the keyboard shortcuts overlay opened with the question-mark key */} ## Troubleshooting [#troubleshooting] **I don't see Admin, or a sidebar item my colleague has.** Cause: what you see depends on your role and permissions, not on a bug or a missing installation step. Fix: ask your workspace administrator to grant the relevant permission if you believe you need it. **The reset-password email never arrives.** Cause: either the address has no account, or the message landed in spam/junk. Fix: check spelling and your spam folder; if it still doesn't show up, ask your administrator to confirm an account exists for that email. **My authenticator code is always "invalid".** Cause: authenticator codes are time-based, so a device clock that has drifted even a little makes every code wrong. Fix: check your phone's clock is set to automatic/network time, then try again. If you are still locked out, use one of your saved recovery codes and set two-factor up again. **My invitation link says it has expired or was already used.** Cause: invitation links are only valid for a limited time and can only be accepted once. Fix: ask whoever invited you to send a new invitation. ## Where to go next [#where-to-go-next] * Create your first project and ticket: [Your first project](/getting-started/first-project) * Plan and track work in depth: [Projects and tickets](/user-guide/work/projects-and-tickets) * Find anything fast: [Search](/user-guide/knowledge/search) * Write and organise knowledge: [Docs and wiki](/user-guide/knowledge/docs-and-wiki) * Record your time and time off: [Time tracking](/user-guide/planning/time-tracking) * Turn a project into a requirements specification or customer report: [Requirements documents and reports](/user-guide/knowledge/requirements-documents) * Set up your account and security: [Account and security](/user-guide/everyday/account-and-security) # orboto cloud (/cloud) orboto cloud is the managed edition: the same product as self-hosted, operated for you. Your workspace lives at its own address, and the platform takes care of everything below the application layer - you sign up, and there is no server, container, or database for you to think about at any point afterwards. ## What cloud manages [#what-cloud-manages] * **Hosting and TLS.** Your workspace runs on managed infrastructure and is reachable over HTTPS from the moment it's provisioned - there is no server to patch, no container image to pull, and no certificate to renew. Compare this with [self-hosting](/self-hosting), where you run Docker Compose yourself and put your own reverse proxy in front. * **Updates.** New versions roll out to your workspace as **non-interrupting updates** by default - you don't see downtime, and you don't have to bump an image tag yourself. When a maintenance action genuinely could affect you (something more than a routine rolling update), you're notified in advance per the service terms before it happens. If your team needs to control exactly when a new version lands - for example to line it up with your own release testing - you can stay **pinned** to a specific version instead of following the rolling channel; ask support to set this for your workspace. * **Backups.** The platform runs automatic backups of your workspace in the background, independently of anything you configure. On top of that, you keep the same self-service export tools as a self-hosted install: trigger a full backup on demand, or set up your own scheduled jobs against your own S3-compatible bucket, from [Admin → Backup](/admin/operations/backup). Cloud does not remove this admin area - it adds a second, platform-side safety net on top of it. * **Email.** Outbound email (invitations, notifications, password resets) works immediately with no setup - there is no SMTP host or API key to configure before your first invitation can be sent. If you'd rather use your own email provider (your own domain's deliverability, your own sending limits), you can still configure one exactly as a self-hosted install would, from **Admin → System Settings → Email delivery**. * **AI features.** Managed AI can be turned on for your workspace without bringing your own OpenAI/Anthropic/Ollama key - the platform provides one. If you'd rather use your own provider account (your own usage billing, your own model choice), bring-your-own-key remains fully supported, configured the same way as self-hosted. * **Scaling and database tuning.** Self-hosting exposes knobs like the number of API worker processes and Postgres memory settings so you can size an instance to your own hardware (see [Scaling beyond one process](/self-hosting/docker#scaling-beyond-one-process-optional)). On cloud, none of that exists as a setting - the platform sizes and scales your workspace's resources for you as usage grows. ## What is identical to self-hosting [#what-is-identical-to-self-hosting] | Area | Cloud | Self-hosted | | -------------------------- | ------------------------------------------------------ | ------------------------------------------------------ | | Features, permissions, API | Same product, same behaviour | Same product, same behaviour | | Data ownership | Full backup export, per-user data export, GDPR tooling | Full backup export, per-user data export, GDPR tooling | | Email providers | Managed by default, or bring your own | You configure a provider yourself | | AI features | Managed key available, or bring your own | Bring your own provider key | | Admin surface | Same admin panel and settings | Same admin panel and settings | Everything in the [user guide](/user-guide) and [admin guide](/admin) applies to cloud and self-hosted alike, down to individual permission slugs and API endpoints - there is no cloud-only or self-host-only feature gate on anything documented there. Where a page differs by edition it says so inline; a handful of operator-facing pages - running Docker, choosing a reverse proxy, tuning Postgres - only apply to self-hosting, because cloud simply has no equivalent step for you to take. ## Your data [#your-data] Your workspace's data is yours: full backup export, per-user data export and the GDPR tooling are available in cloud exactly as in self-hosted installations. Nothing about switching to cloud changes what you can export or delete, or locks anything behind a migration process - the same backup archive format that restores a self-hosted instance is what you'd take with you if you ever moved off cloud. ## Troubleshooting [#troubleshooting] **I can't find where to configure the server, domain, or database.** Cause: those are exactly the pieces cloud manages for you - there is no equivalent setting to look for, by design. Fix: if you need infrastructure-level control (a private network, a specific region, custom scaling), that is a self-hosting decision - see [Self-hosting](/self-hosting). Everything else - projects, users, integrations, AI, notifications - is configured from the same **Admin** area cloud and self-hosted share. **I want an on-demand backup, not just the automatic platform one.** Cause: the automatic platform backup runs on its own schedule and isn't something you trigger yourself. Fix: trigger and download your own export any time from [Admin → Backup](/admin/operations/backup) - available identically on cloud and self-hosted, and independent of the platform's own backups. **I want to stop using cloud and run this myself instead.** Cause: not a limitation - just a decision to make once. Fix: export a full backup archive from Admin → Backup, then follow [Self-hosting with Docker](/self-hosting/docker) and restore that archive during first-run setup on your own instance. # Calendar sync (/integrations/calendar-sync) orboto offers two independent ways to connect a calendar: a read-only **feed** anyone can subscribe to in seconds, and a deeper **provider sync** that pushes your approved absences out and pulls your busy time in. You can use either on its own, or both together. ## Calendar feed (any calendar app) [#calendar-feed-any-calendar-app] The feed is the fastest way to see orboto dates in a calendar you already use - no OAuth, no admin setup. 1. Open your **profile**. 2. Find **Calendar feed** and select **Generate feed URL** (or copy the existing one if you already generated it). 3. Choose which schedule types the feed carries - due dates, milestones, absences. 4. Copy the URL and add it as a **subscription** (not an import) in your calendar app - the exact menu differs per app, but it's the option labeled "Subscribe from URL", "Add calendar by URL", or similar, never "Import" (import is a one-time snapshot; subscribe keeps refreshing). {/* screenshot: the profile page's calendar feed card with the generated URL and type checkboxes */} The URL contains a private, unguessable token - anyone who has the URL can read the events it carries, so treat it like a password. If a URL leaks (pasted somewhere public, shared with the wrong person), select **Regenerate** - the old URL stops working immediately and every subscriber needs the new one. The feed is **read-only** at the consuming end: your calendar app refreshes it periodically (the interval is controlled by the app, not orboto - most refresh every few hours) but you can't edit an orboto event from inside your calendar app and have it write back. ## Provider sync (Google, Microsoft 365, CalDAV) [#provider-sync-google-microsoft-365-caldav] Provider sync is two-way in a specific sense: * **Push** - your *approved* absences become all-day events on a calendar you choose (your primary/default calendar unless you pick a different target). * **Pull** - your busy windows from that external calendar appear as a read-only hatched overlay on your **Time Calendar**, so you can plan orboto work against your real availability. orboto only ever reads *busy/free* status, never event titles or details - this is a deliberate privacy choice, not a current limitation. {/* screenshot: the Time Calendar with the hatched busy-time overlay next to orboto events */} Three providers are supported, each connected the same way from **Profile → Connected calendars**, but set up differently underneath. ### Google Calendar [#google-calendar] **One-time workspace setup (operator).** Before any user can connect Google Calendar, an operator registers one OAuth client in the Google Cloud Console that every user connects through: 1. Open the [Google Cloud Console](https://console.cloud.google.com/) and pick (or create) a project. 2. **APIs & Services → Library** → enable the **Google Calendar API**. 3. **APIs & Services → OAuth consent screen** → configure it (External or Internal - Internal is simplest if every orboto user is in the same Google Workspace organization). 4. **APIs & Services → Credentials → Create credentials → OAuth client ID** → application type **Web application**. 5. Under **Authorized redirect URIs**, add exactly: ``` https:///calendar/callback/google ``` Use the exact host your workspace serves on. Add one entry per host if you run more than one (e.g. staging and production). 6. Set the resulting **Client ID** and **Client secret** as environment variables on the orboto deployment: ``` GOOGLE_CALENDAR_CLIENT_ID=... GOOGLE_CALENDAR_CLIENT_SECRET=... ``` Until both are set, **Google Calendar** simply doesn't appear in the "+ Connect" list on any user's profile - there's nothing to misconfigure partway. **Why two scopes.** The connection requests `https://www.googleapis.com/auth/calendar.events` (to push - insert, update, delete the absence events orboto owns) and `https://www.googleapis.com/auth/calendar.readonly` (to list your calendars for the target picker, and to read free/busy for the overlay). The write scope alone can't enumerate calendars or query free/busy, so the read scope is added on top - it's additive, not a downgrade. **Per-user connect flow:** 1. **Profile → Connected calendars → Connect Google Calendar.** 2. Your browser is sent to Google's consent screen. 3. Approve access. Google redirects back to orboto. 4. orboto stores the connection (encrypted) and runs an initial sync: your currently-approved absences get pushed, and the busy overlay fills in on the next scheduled pull. 5. Back on your profile, pick a **target calendar** (defaults to your primary one) and toggle **push** / **pull** independently - you can run either direction alone if you only want one. **Keeping the connection alive.** Google issues short-lived access tokens (about an hour); orboto refreshes them automatically ahead of expiry using the refresh token from your original consent - you never see this happen. If you **revoke orboto's access from your Google account**, or Google otherwise invalidates the grant, the *next* sync attempt fails, the connection is marked inactive, and you get a notification asking you to reconnect. Reconnecting just repeats the connect flow above. **If two edits collide.** If you (or Google Calendar) also edit one of the events orboto pushed, the next sync overwrites your external edit with orboto's version - orboto is authoritative for what it pushed. This only applies to events orboto created; anything else on your calendar is untouched. ### Microsoft 365 / Outlook [#microsoft-365--outlook] **One-time workspace setup (operator).** Register one Microsoft Entra ID (Azure AD) app that every user connects through: 1. Open the [Azure portal](https://portal.azure.com/) → **Microsoft Entra ID → App registrations → New registration**. 2. Name it (e.g. "orboto calendar sync"). 3. Choose **Supported account types** - this decides single-tenant vs multi-tenant (see below): * *Accounts in this organizational directory only* → single-tenant. * *Accounts in any organizational directory* (optionally *and personal Microsoft accounts*) → multi-tenant. 4. Under **Redirect URI**, pick platform **Web** and add exactly: ``` https:///calendar/callback/microsoft ``` 5. **API permissions → Add a permission → Microsoft Graph → Delegated permissions** → add **`Calendars.ReadWrite`** and **`offline_access`**. Grant admin consent if your tenant requires it. 6. **Certificates & secrets → New client secret** → copy the secret **value** immediately (Azure only shows it once). 7. Set these as environment variables on the orboto deployment: ``` MICROSOFT_CALENDAR_CLIENT_ID=... MICROSOFT_CALENDAR_CLIENT_SECRET=... MICROSOFT_CALENDAR_TENANT=common ``` Use `common` for multi-tenant (any work/school account across any Entra tenant can connect - the broadest reach, but a foreign tenant's admin may need to consent separately). Use your directory (tenant) id instead for single-tenant (only your own organization's accounts can connect - the tightest scope, and the right default when every orboto user is in the same Microsoft org). Until the client id and secret are set, **Microsoft 365** doesn't appear in the "+ Connect" list. **Why one scope covers both directions.** Unlike Google, `Calendars.ReadWrite` alone covers push AND the reads needed for the calendar picker and the busy overlay - so Microsoft's scope set stays to just that plus `offline_access` (needed to get a refresh token, since access tokens only last about an hour). **Per-user connect flow:** 1. **Profile → Connected calendars → Connect Microsoft 365.** 2. Consent on Microsoft's page. 3. orboto stores the connection and runs an initial sync. 4. Pick a **target calendar** (defaults to your default calendar) and toggle push / pull. **Keeping the connection alive.** orboto refreshes access tokens proactively before they expire, and Microsoft rotates the refresh token on every refresh - orboto always re-stores the newest one, so this requires no attention from you. If Microsoft's API is temporarily rate-limiting requests, orboto waits and retries automatically; you'd only notice as a slightly delayed sync, never a failure. As with Google, if you revoke access or the grant otherwise becomes invalid, the next sync fails, the connection is marked inactive, and you're notified to reconnect. **If two edits collide,** the same rule as Google applies: orboto is authoritative for events it pushed, and overwrites external edits to those specific events on the next sync. ### CalDAV (self-hosted / other providers) [#caldav-self-hosted--other-providers] CalDAV is the option for any RFC-4791 server - Nextcloud, Mailcow, Fastmail, Posteo, mailbox.org, Apple iCloud, and more - and needs **no workspace setup**. It always appears under "+ Connect" because there's no OAuth app to register first; you connect directly with a server URL and credentials. **Per-user connect flow:** 1. **Profile → Connected calendars → CalDAV.** 2. Fill in: * **Server URL** - the CalDAV entry point for your provider (see the per-provider values below). A quick-preset chip row prefills the common ones. * **Username** - usually your email address or account login. * **Password** - for any account with two-factor authentication (iCloud, Fastmail, many mailbox providers) this **must** be an app-specific password, not your normal login password; your regular password will be rejected by the provider itself. 3. orboto verifies the credentials against the server before saving anything - a wrong URL or password fails immediately with a clear error, nothing partial gets stored. On success it lists your calendars and pre-selects the primary one as the sync target; change the target any time from the account card. **Per-provider server URL and password notes:** | Provider | Server URL | Password | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Nextcloud / ownCloud | `https:///remote.php/dav` | Your login password, or an app password from **Security → Devices & sessions** if 2FA is on. | | Fastmail | `https://caldav.fastmail.com` | An app password from **Settings → Privacy & Security → App passwords**, scoped to CalDAV - your normal password won't work. | | Apple iCloud | `https://caldav.icloud.com` | An app-specific password from [account.apple.com](https://account.apple.com) → Sign-In & Security → App-Specific Passwords. iCloud rejects your plain Apple ID password over CalDAV. | | Mailcow (SOGo) | typically `https:///SOGo/dav` | An app password where the provider offers one. | | Posteo, mailbox.org, and other generic RFC-4791 servers | the provider's published CalDAV endpoint (or your account's direct calendar URL if that's all you have - discovery degrades gracefully) | An app password where the provider offers one. | **Sync timing.** Push is event-driven: approving, updating, or cancelling an absence sends the change to your calendar within moments. Pull runs on a schedule (about every 15 minutes) and covers the next 30 days of busy time; if nothing changed on your calendar since the last pull, orboto reuses the cached result instead of re-fetching, so an unchanged calendar doesn't cost extra requests. **If the server later rejects your stored credentials** (password rotated, app password revoked), the next sync marks the account **Reconnect** and notifies you. Re-enter the credentials from the account card to resume - nothing else about the connection changes. **Self-hosted note.** If your CalDAV server lives on a private network address (e.g. a LAN Nextcloud instance), the operator needs to set `WEBHOOK_ALLOW_PRIVATE_IPS=true` on the orboto deployment - by default, outbound requests to loopback/private/link-local addresses are blocked as an SSRF guard. This setting affects every outbound user-supplied URL on the instance (webhooks included), so only enable it when the orboto instance itself runs inside a trusted network. ## Invitations [#invitations] Scheduled items that involve other people (for example, planning sessions) go out as standard calendar invitations attached to the notification email - accepting one adds it to whatever calendar app you open the invitation with, independent of the feed and provider-sync paths above. ## Troubleshooting [#troubleshooting] | Symptom | Fix | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | The feed shows nothing / stopped updating | Confirm you subscribed (not imported) - an import is a one-time snapshot and will never update again. Also check the feed still has schedule types selected in your profile; an empty selection carries zero events by design. | | Feed URL was pasted somewhere it shouldn't have been | Regenerate it from your profile immediately - the old URL stops working the instant you do, cutting off whoever has it, but every legitimate subscriber (including your own devices) needs to re-subscribe with the new one. | | A provider doesn't appear in "+ Connect" | For Google or Microsoft 365, this means the workspace-level OAuth app hasn't been registered yet - that's an operator task (see the setup steps above), not something a user can self-serve. CalDAV always appears; if it doesn't, something's wrong with the instance itself. | | Connection shows "Reconnect" | The stored credentials or token grant stopped working - most often you (or your IT) revoked access on the provider's side, a password rotated, or an app password was regenerated. Reconnect using the same steps as the initial connect. | | Busy overlay is empty even though the connection is active | Pull runs on its own schedule (roughly every 15 minutes for CalDAV; Google/Microsoft similarly poll rather than push instantly) - give it a few minutes after connecting. If it's still empty after that, check the connected calendar actually has events in the next 30 days, and that **pull** is toggled on for that connection. | | An event orboto pushed keeps reverting your manual edit | Expected behavior - orboto is authoritative for events it created and overwrites external edits to them on the next sync. Edit the absence in orboto instead of the calendar event directly. | | CalDAV connect fails against a server on your local network | If the CalDAV server is on a private/LAN address, ask your operator whether `WEBHOOK_ALLOW_PRIVATE_IPS` is enabled - by default the SSRF guard blocks private-network destinations. | # Chat and webhook notifications (/integrations/chat-notifications) Beyond in-app and email, every user can route their own notifications to an external channel: **Discord**, **Slack**, **Microsoft Teams**, **Telegram**, or a **custom webhook** to any HTTPS endpoint. Channels are per-user - each person connects their own, and picks which notification types go to which channel (for example, only mentions and assignments to chat, everything else staying in-app). ## Connecting a channel [#connecting-a-channel] Open **notification settings** (gear icon in the top bar) and add a channel of the type you want. ### Discord [#discord] 1. In your Discord server: **Server Settings → Integrations → Webhooks → New Webhook**. 2. Copy the webhook URL and paste it into the channel form. ### Slack [#slack] 1. In your Slack workspace: **Apps → Incoming Webhooks**. 2. Add the integration to the channel you want notifications posted in. 3. Copy the generated webhook URL and paste it into the channel form. ### Microsoft Teams [#microsoft-teams] 1. In the target Teams channel: **⋯ → Connectors → Incoming Webhook → Configure**. 2. Name the connector (e.g. "orboto") and optionally upload an icon, then **Create**. 3. Copy the webhook URL and paste it into the channel form. ### Telegram [#telegram] Telegram needs one piece of workspace-level setup before any user can connect it: 1. **Workspace admin, one-time**: create a bot via [BotFather](https://t.me/BotFather) and set the resulting bot token under **Admin → System settings → Telegram bot token**. This enables Telegram as a channel type for the whole workspace. 2. **Each user**: start a chat with the workspace's bot and send `/start` - it replies with your chat ID. Paste that chat ID into your notification settings. ### Custom webhook [#custom-webhook] Point notifications at any HTTPS endpoint - useful for Zapier, n8n, Make, Power Automate, or a bot you run yourself. Each notification arrives as a JSON POST. * **Shared secret** (optional) - when set, every request carries an `X-Orboto-Signature: sha256=` header so your endpoint can verify it actually came from orboto. * **Custom headers** - up to 5 extra HTTP headers on every request. Reserved keys (`Authorization`, `X-Orboto-Signature`, `User-Agent`, `Content-Type`) are stripped server-side, so a header you set can't collide with orboto's own. {/* screenshot: the add-channel form with the type picker (Discord/Slack/Teams/Telegram/webhook) */} ## Verify the channel [#verify-the-channel] After saving, select **Verify** to send a test message. A channel stays **unverified** - and is excluded from delivery, even with its per-event toggle on - until it successfully receives that test. This catches a mistyped URL or an unconfirmed Telegram chat ID before it costs you a missed notification later. Once verified, pick which notification types go to which channel - see [Notifications](/user-guide/everyday/notifications) for the full per-event routing and the notification inbox itself. ## Behavior [#behavior] Delivery is fire-and-forget: a failing channel never blocks orboto, and every attempt lands in a delivery log so you (or an administrator) can diagnose a silent channel. ## Troubleshooting [#troubleshooting] | Symptom | Fix | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A channel never receives anything | Check its status first - an **unverified** channel is excluded from delivery even if its per-event toggle is on. Select **Verify** again. | | Discord/Slack/Teams channel stopped working after previously verifying fine | The integration was likely deleted or regenerated on that platform's side - all three let you remove or reissue a webhook independently of orboto, which silently breaks delivery until you paste the new URL and re-verify. | | Telegram option doesn't appear in the channel type picker | An admin hasn't set the workspace's Telegram bot token yet (**Admin → System settings**) - Telegram only shows up as a choice once that's configured. | | Telegram messages don't arrive despite a valid chat ID | Send `/start` to the bot again from the account you expect notifications on - chat IDs are per Telegram account, and a chat that never messaged the bot can't receive a push from it. | | Custom webhook signature doesn't verify | Compute the HMAC over the **raw** request body using the shared secret you set, not a re-serialized copy of the parsed JSON. | # Integrations (/integrations) orboto connects to the tools around it - AI clients, code repositories, chat tools, and email. Some connections you set up yourself; the workspace-wide ones are handled by an administrator. This chapter covers the parts you touch as a regular user, and points you to the admin material for the rest. ## Connecting an AI client to your workspace [#connecting-an-ai-client-to-your-workspace] You can connect an AI client - such as Claude Desktop, Cursor, or a Copilot-style assistant in your editor - to orboto, so it can read and update your tickets and docs directly from that tool. orboto speaks the **Model Context Protocol** (MCP), the standard these clients use to talk to external systems. The usual flow: 1. In your AI client, add orboto as an MCP connection using the address your workspace provides. 2. Your browser opens an orboto **consent screen** asking you to approve the connection for your account. 3. The consent screen also lets you choose **which identity the connection acts as** - yourself (the default: every ticket, comment, and timer the client touches is attributed to you, with your exact permissions), or an AI agent account you own, if your workspace uses those. Choosing an agent account keeps your own personal timer and timesheet untouched, since the connection's actions are attributed to the agent instead. 4. Review what it will be allowed to do and **approve**. The client is now connected and acts with the chosen identity's permissions - it can never see or change more than that account can. {/* screenshot: the AI-client connection consent screen with the identity picker */} If you decline, or close the browser tab without approving, nothing is connected - the client simply reports it couldn't authorize, and no partial access is granted. You can revisit and approve later from the same "add connection" step in your client. **Revoking access later.** Open **Profile → Connected AI clients** to see every client that's ever connected, when it last used the connection, and which identity it's acting as. Remove one you no longer use, or one you don't recognize, and its access ends immediately - the next call that client makes fails authentication rather than silently keeping working. {/* screenshot: the Connected AI clients list on the Profile page */} Anything the connected client does respects your roles and access exactly as if you did it in the app. For the detailed setup steps per client, see the [MCP setup guide](/agents-ai/mcp). ## Chat notifications [#chat-notifications] To send orboto notifications into Discord, Slack, Microsoft Teams, Telegram, or a custom webhook, set up a channel in **Notification settings** - see [Chat and webhook notifications](/integrations/chat-notifications) for the exact connection steps per platform, and [Notifications](/user-guide/everyday/notifications) for routing individual event types to a channel. ## Code repositories (Git) [#code-repositories-git] When your workspace is connected to a code host (such as GitHub or GitLab), mentioning a ticket key in a commit message or pull request links that work to the ticket automatically. You then see linked commits and pull requests on the ticket's **Git** tab (see [Projects and tickets](/user-guide/work/projects-and-tickets)). Connecting the repository itself is an administrator task - see the [administration](#administration-topics) pointers below. ## Administration topics [#administration-topics] The following are set up and run by workspace administrators, not individual users. They are documented separately; the most relevant references already in this repository are linked here: * **Inviting and managing users, roles, and permissions** - the admin area. * **Single sign-on (SSO)** - [sso-setup.md](/admin/identity/sso). * **Automatic user provisioning (SCIM)** - connect your identity provider so joiners and leavers sync automatically (setup guide coming to this section). * **Importing from another tool** (issues, projects, CSV) - the admin import area. * **Backups and restore** - [backup-destinations.md](/self-hosting/backups). * **Inbound email to tickets** - [inbound-email.md](/admin/email/inbound-email). * **The MCP server and AI provider configuration** - [mcp-setup.md](/agents-ai/mcp) and the admin AI settings. * **Webhooks, API keys, audit log, compliance export, and federation** - the admin area. If you need any of these enabled or changed, contact a workspace administrator. # n8n operation reference (/integrations/n8n-reference) This page is the complete field-by-field reference for the [`n8n-nodes-orboto`](/integrations/n8n) package: every operation on the **orboto** action node, grouped by resource, and every event the **orboto Trigger** node can fire on. For installation, credentials, error-handling semantics (422/409/423/429), and a worked walkthrough, see [n8n](/integrations/n8n) first - this page assumes you've already connected a credential and just need to know what a specific operation expects. A few conventions that apply across every resource below: * **"Project"** fields are dropdowns that load your orboto projects live - you rarely need to type a key or ID by hand. * **"Ticket"** fields accept a ticket **key** (`ACME-42`), a bare **number** (`42`, resolved against the Project field), or a **UUID** - whichever is most convenient in your workflow. * Fields marked *(loaded live)* are dropdowns populated from your connected orboto instance (statuses, members, labels, milestones, versions, doc spaces) rather than typed by hand. * A field left blank on **Update**-style operations is left untouched on the ticket/milestone/doc - see [n8n](/integrations/n8n) for the general error-handling and rate-limit notes that apply to every operation. ## Ticket [#ticket] | Operation | Required fields | Optional fields | Notes | | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Create | Project, Title | Description, Type, Priority, Status *(loaded live)*, Milestone *(loaded live)*, Version *(loaded live)*, Label Names *(loaded live, multi)*, Assignee Emails (comma-separated), Parent Ticket, Start Date, Due Date, Estimated Time (minutes), Private, Delivery Mode, Skip Auto-Translate, Allow Language Mismatch, Allow Duplicate + Duplicate Justification | Type: `epic`/`story`/`task`/`bug`. Priority: `blocker`/`high`/`normal`/`low`/`trivial`. Delivery Mode: `implementation`/`docs`/`review`/`admin`/`epic`. Duplicate Justification is required when Allow Duplicate is on. | | Get | Project, Ticket | - | | | Get Many | Project | Return All / Limit, Status Category, Assignee *(loaded live)*, Milestone Filter *(loaded live)*, Parent Ticket Filter, Search, Include Closed Milestones | Status Category: `todo`/`in_progress`/`in_review`/`done`/`wont_fix`. | | Update | Project, Ticket | Update Fields collection: Description, Type, Priority, Status, Milestone, Version, Start Date, Due Date, Estimated Time, Private, Delivery Mode, Skip Auto-Translate, Label Names, Assignee Emails, Parent Ticket; Allow Language Mismatch | Only fields explicitly added to the Update Fields collection are changed - everything else on the ticket is left alone. | | Delete | Project, Ticket | - | Permanent unless the ticket is under legal hold (423). Prefer moving to `wont_fix` over deleting when in doubt. | | Move (Change Status) | Project, Ticket, Status *(loaded live)* | - | | | Assign / Unassign | Project, Ticket, User *(loaded live)* | - | | | Comment | Project, Ticket, Comment | Internal | Comment body is Markdown. Internal comments aren't visible to customers. | | Log Time | Project, Ticket, Duration (minutes) | Time Description, Logged At | Logged At defaults to now. | | Add Label / Remove Label | Project, Ticket, Label *(loaded live)* | - | | | OQL Query | Query, Syntax | Return All / Limit | Syntax: `oql` or `jql`. No Project field - the query itself scopes the results. Paginates automatically via cursor until Limit (or all results, with Return All) is reached. | | Set Milestone | Project, Ticket, Milestone *(loaded live)* | - | | | Clear Milestone | Project, Ticket | - | | | Set Version | Project, Ticket, Version *(loaded live)* | - | | | Clear Version | Project, Ticket | - | | | Add Dependency / Remove Dependency | Project, Ticket, Depends On Ticket | - | | | Get Dependencies | Project, Ticket | - | | | Checklists: Add Item | Project, Ticket, Checklist ID, Item Text | - | | | Checklists: Check Item / Uncheck Item | Project, Ticket, Item ID | - | | | Get Attachments | Project, Ticket | - | | | Add Attachment | Project, Ticket, Binary Property | - | Binary Property names the input item's binary field holding the file (default `data`). | | Bulk Update | Project, Ticket IDs (comma-separated), Action | Value, shaped per Action | Action: `status` / `milestone` / `assignee` / `version` / `priority` / `due_date`, each with its own live-loaded or plain Value field. Prefer this over looping per-ticket updates for large batches. | ## Milestone [#milestone] | Operation | Required fields | Optional fields | | --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | Create | Project, Name | Milestone Key, Start Date, End Date, Budget Amount, Budget Hours, Private | | Get | Project, Milestone (key or ID) | - | | Get Many | Project | Include Closed | | Update | Project, Milestone | Milestone Key, Start Date, End Date, Status (`active`/`completed`/`archived`), Budget Amount, Budget Hours, Private | | Close | Project, Milestone | - | Close is a convenience wrapper around Update with Status set to `completed`. ## Project [#project] | Operation | Required fields | Optional fields | | ---------------- | ------------------- | ------------------------------------------------------------------------------- | | Create | Name | Key, Description, Language | | Get | Project (key or ID) | - | | Get Many | - | - | | Update | Project | Name, Key, Description, Language, Status (`draft`/`active`/`archived`/`closed`) | | Get Primer Facts | Project | - | | Get AI Primer | Project | - | Key must be 2-10 uppercase characters (letters and digits). Language is the ticket language enforced for that project - see [n8n](/integrations/n8n) for how the node surfaces a language mismatch. ## Doc [#doc] | Operation | Required fields | Optional fields | Notes | | -------------- | --------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------- | | Create | Space *(loaded live)*, Title | Content, Visibility | Visibility: `public`/`workspace`/`members`/`specific`, default `workspace`. | | Get | Doc (key or ID) | - | | | Get Many | Space | - | | | Update | Doc | Title, Content, Visibility | Only the fields set are changed. | | Ask Docs (RAG) | Question | Limit Results | Requires an embedding provider configured on the instance - a clear error explains this if none is set up. | | Ingest URL | Space, URL | Raw HTML | Pass Raw HTML to ingest pre-fetched content instead of letting orboto fetch the URL itself. | ## Time Entry [#time-entry] | Operation | Required fields | Optional fields | | --------- | --------------------------------------------- | ---------------------- | | Log | Project, Ticket, Duration (minutes) | Description, Logged At | | Get Many | Project, Ticket | - | | Edit | Project, Ticket, Entry ID, Duration (minutes) | Description | | Delete | Project, Ticket, Entry ID | - | All time-entry operations are ticket-scoped. ## User [#user] | Operation | Required fields | | ------------------- | --------------- | | Get Project Members | Project | There's no global user directory operation - member reads are always scoped to one project, matching the fact that orboto has no workspace-wide "all users" list a non-admin can read. ## Label [#label] | Operation | Required fields | Optional fields | | --------- | --------------- | --------------- | | Create | Project, Name | Color | | Get Many | Project | - | ## Saved Search [#saved-search] | Operation | Required fields | Optional fields | Notes | | --------- | ------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Create | Name, Query Type | OQL Query (when Query Type is `oql` or `jql`) | Query Type: `oql`/`jql`/`legacy`. | | Get Many | - | - | | | Run | Saved Search (name or ID) | - | Only OQL/JQL saved searches can be run from n8n - a `legacy`-type saved search fails with a clear error, since legacy queries have no OQL/JQL text to execute. | ## Agent [#agent] The n8n-to-agent bridge - lets a workflow talk to an [agent identity](/agents-ai/work-routing)'s inbox the same way one agent notifies another. | Operation | Required fields | Optional fields | Notes | | ------------ | ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Notify | Target Email, Subject | Kind, Payload (JSON), Thread ID, Project Scope, Sender Reference | Kind: `info` (FYI), `request` (expects an answer), `complete` (closes a request), `error` (reports failure). Project Scope routes the message to the right session. | | Get Messages | - | Include Already-Delivered, Limit, Exclude Reference, Project Scope | By default returns only messages not yet delivered to this reader. | | Ack Messages | Message IDs (comma-separated) | - | Marks messages as delivered so a later Get Messages call doesn't return them again. | ## orboto Trigger: events [#orboto-trigger-events] The trigger's **Events** field lists these 16 events; check any combination. An **Additional Events** free-text field (comma-separated) lets you subscribe to event types added to orboto after this package version shipped, before the node has a dedicated checkbox for them. | Event | Fires when | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ticket.created` | A ticket is created. | | `ticket.updated` | A ticket's fields change. | | `ticket.deleted` | A ticket is deleted. | | `ticket.ready` | A ticket becomes unblocked (its dependencies are resolved). | | `ticket.checklist_item.completed` | A checklist item on a ticket is checked. | | `comment.created` | A comment is posted. | | `comment.updated` | A comment is edited. | | `comment.deleted` | A comment is deleted. | | `project.member_added` | A member joins a project. | | `project.member_removed` | A member leaves a project. | | `milestone.created` | A milestone is created. | | `milestone.updated` | A milestone's fields change. | | `version.released` | A version is marked released. | | `symphony.candidates_changed` | A ticket's status, assignee, or priority changes, in a shape relevant to a [Symphony](/integrations/symphony) coding-agent orchestrator polling for work. | | `inbound.signal.received` | An inbound signal (e.g. from inbound email processing) is received. | | `agent.escalation_raised` | An AI agent raises an escalation that needs human attention. | Every delivery is scoped to the **Project** you selected on the trigger node - webhooks in orboto are project-scoped, so one trigger node watches one project. Add a second orboto Trigger node for a second project. # n8n (/integrations/n8n) `n8n-nodes-orboto` is the official community node for the n8n workflow platform. It brings two nodes: * **orboto** (action node): tickets, milestones, projects, docs, time entries, labels, saved searches, and agent messaging. * **orboto Trigger**: fires your workflow on workspace events, delivered as HMAC-verified webhooks. ## Install [#install] In n8n: **Settings → Community nodes → Install** and enter `n8n-nodes-orboto`. Self-hosted n8n can also install it manually into the user folder and restart: ```bash cd ~/.n8n/custom npm install n8n-nodes-orboto ``` Community nodes are enabled by default on both n8n Cloud and self-hosted instances. ## Credentials [#credentials] Two credential types, both accepting a self-hosted base URL (either `https://orboto.example.com` or `https://orboto.example.com/api` work): * **orboto API** (simplest) - an API key from orboto (**Profile → API keys**, or an admin-issued bot key) plus your instance base URL. Use a dedicated [bot identity](/admin/identity/users-and-roles) per workflow so activity stays attributable and revocable. The credential test performs an authenticated read for immediate feedback. * **orboto OAuth2 API** - for instances with OAuth enabled. An administrator creates a static OAuth client under **Admin → OAuth clients** in orboto; paste its client id (and secret, if confidential) into the credential. The flow is PKCE with the `api offline_access` scope, so tokens refresh automatically, and the authorize/token URLs are discovered from the base URL. {/* screenshot: the orboto credential setup form in n8n */} ## The orboto node [#the-orboto-node] Action node covering the orboto REST API, organized by resource: | Resource | Operations | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Ticket | Create, Get, Get Many (filters, Return All/Limit), Update, Delete, Move (change status), Assign, Unassign, Comment, Log Time, Add Label, Remove Label, OQL Query, Set/Clear Milestone, Set/Clear Version, Dependencies (Add/Remove/Get), Checklists (Add Item, Check, Uncheck), Attachments (Add, Get Many), Bulk Update | | Milestone | Create, Get, Get Many, Update, Close | | Project | Create, Get, Get Many, Update, Get Primer Facts, Get AI Primer | | Doc | Create, Get, Get Many, Update, Ask Docs (RAG), Ingest URL | | Time Entry | Log, Get Many, Edit, Delete (ticket-scoped) | | User | Get Project Members | | Label | Create, Get Many | | Saved Search | Create, Get Many, Run (OQL/JQL) | | Agent | Notify, Get Messages, Ack Messages - the n8n-to-agent bridge | Ticket references accept a key (`ACME-42`), a number (`42`), or a UUID anywhere the node asks for one. Dropdowns for project, milestone, status, member, label, version, and doc space load live from your connected instance instead of requiring you to paste ids. **Rate limit**: 600 requests/minute per instance. For large batches, prefer the bulk-update operation over looping per-ticket updates, and turn on **Continue On Fail** so one bad row doesn't stop the whole run. **Error handling as first-class options**, not opaque failures: * **422 language enforcement** - enable *Allow Language Mismatch* to create/update anyway; the response carries a `languageWarning`. * **409 duplicate block** - enable *Allow Duplicate* plus a required justification; `similarWarnings` land in the node output so the workflow can branch on them. * **423 legal hold** (delete) and **429 rate limit** - both documented on the relevant operation, with the retry guidance you'd expect. ## The orboto Trigger node [#the-orboto-trigger-node] Fires your workflow on orboto events over the webhook system: * **Auto-registers** a project-scoped webhook when you **activate** the workflow, and removes it when you **deactivate** it - no manual webhook setup in orboto. * Every delivery is verified against the `X-Orboto-Signature` header (HMAC-SHA256, constant-time compare); unsigned or forged requests are rejected before your workflow runs. * **Events**: `ticket.created`, `ticket.updated`, `ticket.deleted`, `ticket.ready`, `ticket.checklist_item.completed`, `comment.created`, `comment.updated`, `comment.deleted`, `project.member_added`, `project.member_removed`, `milestone.created`, `milestone.updated`, `version.released`, `symphony.candidates_changed`, `inbound.signal.received`, `agent.escalation_raised` - plus a free-text field so a newer orboto version's events still work before the node ships a dedicated dropdown entry for them. n8n must be reachable from your orboto instance for the webhook delivery to land - set `WEBHOOK_URL` / `N8N_WEBHOOK_URL` if n8n runs behind a reverse proxy. {/* screenshot: the orboto Trigger node's event picker */} ## Walkthrough: notify a chat channel on new tickets [#walkthrough-notify-a-chat-channel-on-new-tickets] This builds the smallest complete workflow - two nodes - so you can see exactly how the trigger and the credential fit together before combining them into something bigger. 1. **Create a new workflow** in n8n and add an **orboto Trigger** node as the starting node (n8n prompts you to pick a trigger when the canvas is empty). 2. On the node, select your **orboto OAuth2 API** or **orboto API** credential (see [Credentials](#credentials) above - create one first if you haven't). 3. Set **Project** - the dropdown loads your orboto projects live, so pick one instead of typing a key. 4. Under **Events**, check **`ticket.created`**. Leave the other events unchecked - each checked event adds to the same webhook registration, so you can always come back and add more later. 5. Add a second node - an HTTP Request node pointed at your chat tool's incoming webhook, or any messaging node your n8n instance has available. Connect it to the orboto Trigger node's output. 6. In the second node, reference the trigger's output data to build your message - for example `{{$json.ticket.key}}: {{$json.ticket.title}}` pulls the new ticket's key and title straight from the event payload. 7. **Save**, then **Activate** the workflow (the toggle in the top right). Activating is what actually registers the webhook in orboto - nothing fires while the workflow is only saved as a draft. 8. Create a test ticket in the project you selected. Within moments, the second node should run and your chat channel should receive the message. If nothing arrives, work through the [Troubleshooting](#troubleshooting) table below - the two most common causes are the workflow not actually being activated, and n8n's webhook URL not being reachable from your orboto instance. ## Example flows [#example-flows] Beyond the walkthrough above, these are the other patterns worth knowing about - all use the same two nodes, just wired differently: * **Scheduled work**: a cron trigger creates a recurring ticket, labeled `agent:` so your [agent fleet](/agents-ai/work-routing) picks it up. * **Event bridge**: orboto Trigger on `ticket.created` posts to your team chat. * **Nightly digest**: a Schedule Trigger runs an OQL query and posts an aggregated summary - e.g. everything closed in the last 24 hours. * **Escalation**: trigger on comments matching a keyword like "BLOCKED", notify a human channel. * **Two-way sync**: orboto Trigger pushes changes outbound to an external system, and an inbound webhook from that system updates the orboto ticket back - with a loop guard so the two directions don't fight each other. The package ships importable example workflows covering these patterns, installed alongside the node. ## Troubleshooting [#troubleshooting] | Symptom | Fix | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Credential test fails | Confirm the base URL is reachable from wherever n8n runs (not just your browser), and that the API key hasn't been revoked. Both `https://host` and `https://host/api` are accepted - try the other form if one fails. | | orboto Trigger never fires | Check the workflow is **activated** (the webhook only registers on activation) and that n8n's public webhook URL is reachable from your orboto instance - set `WEBHOOK_URL`/`N8N_WEBHOOK_URL` if n8n sits behind a proxy. | | Trigger fires but the workflow shows a signature error | The delivery's `X-Orboto-Signature` didn't match - this usually means the webhook was edited or re-created outside n8n after the trigger registered it. Deactivate and reactivate the workflow to re-register cleanly. | | Bulk ticket updates fail partway through | Enable **Continue On Fail** on the node so one bad row doesn't abort the batch, then re-run on just the failed items. | | A create/update silently returns a warning instead of failing | Check the node output for `languageWarning` or `similarWarnings` - these are the 422/409 guardrails surfacing as data rather than an error, by design; branch on them in your workflow. | | Hitting rate limits on a large sync | 600 requests/minute per instance - batch with the bulk-update operation, add a wait node between large loops, or split the sync across a schedule. | # SCIM provisioning (/integrations/scim) SCIM keeps orboto's user list in lockstep with your identity provider (IdP): joiners get orboto accounts automatically when IT adds them in the IdP, leavers are deactivated the moment IT disables them there, and profile updates (name, department, manager) flow through without anyone touching orboto directly. It's the natural next step after [single sign-on](/admin/identity/sso) - SSO handles logging in, SCIM handles who exists to log in as. SCIM is an Enterprise capability, configured entirely under **Admin → SCIM provisioning**. ## What SCIM does, concretely [#what-scim-does-concretely] orboto implements the standard SCIM 2.0 user-provisioning protocol (RFC 7644), which means: * Your IdP can **create** a new orboto user the moment someone is assigned the app in the IdP. * Your IdP can **update** a user's profile fields (name, email, department, manager, employee number) whenever they change in the IdP * orboto stays a mirror, not a second source of truth. * Your IdP can **deactivate or delete** a user the moment they're unassigned or offboarded - see [Deprovisioning](#deprovisioning-deactivate-vs-delete) for the exact difference between those two actions. * orboto also answers the IdP's discovery calls (what resource types and schemas it supports) and supports the enterprise-user schema extension, so fields like `employeeNumber`, `department`, and `manager` map correctly instead of being dropped. Group provisioning isn't available yet - user provisioning covers the common IdP setups used today; if your IdP workflow depends on group-based assignment specifically, confirm your rollout plan accounts for that gap. ## Setup [#setup] 1. Under **Admin → SCIM provisioning**, enable provisioning. orboto shows the **SCIM endpoint URL** you'll need (`https:///api/scim/v2`). 2. Select **Mint a provisioning token**. The bearer token is displayed **once** - copy it immediately into your IdP's configuration; if you navigate away before pasting it, you'll need to mint a new one (the plaintext is never shown again, only a masked reference). 3. In your IdP's provisioning app, set the SCIM base URL and the bearer token from steps 1-2. Every major IdP has a **Test connection** button at this stage - use it before turning anything on for real users. 4. **Test with a single user first.** Assign one test account in the IdP, confirm it appears correctly in orboto (right name, right email, right department if you're using the enterprise extension), then deactivate that same test user in the IdP and confirm it deactivates in orboto too. Only after both directions check out, assign the full group of users. {/* screenshot: the Admin -> SCIM provisioning page with the endpoint URL and token panel */} ### Per-IdP setup [#per-idp-setup] The general shape (base URL + bearer token) is the same everywhere; here is exactly where each major IdP asks for it: | Identity provider | Where to configure it | What goes in the auth field | | ----------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | | Azure AD / Microsoft Entra ID | Enterprise App → Provisioning → Automatic | **Tenant URL** = the SCIM endpoint URL, **Secret Token** = the minted token | | Okta | App → Provisioning → Integration → Enable API integration | **Base URL** = the SCIM endpoint URL, **API Token** = the minted token | | Google Workspace | Apps → Web and mobile apps → Auto-provisioning | SCIM endpoint URL + the minted bearer token | | JumpCloud | SSO app → Identity Management → Configure | Base URL + the minted bearer token | orboto is verified against the exact payload quirks of all four - including Azure AD sending booleans as strings (`"true"` instead of `true`) and Okta's PATCH requests that sometimes omit the `path` attribute the spec technically expects. You don't need to work around either; orboto's endpoint already handles them. If your IdP isn't one of these four, any standards-compliant SCIM 2.0 client should still work - the endpoint follows RFC 7644 rather than special-casing a fixed vendor list. ## Token rotation [#token-rotation] Mint, rotate, and revoke tokens from **Admin → SCIM provisioning**. * **Rotate** issues a brand-new token and keeps the **old one valid for a 24-hour overlap window**. This is the safe way to change tokens: update the IdP with the new token at your own pace within that window, rather than the provisioning breaking the instant you rotate. * **Revoke** kills a token immediately, no overlap. Use this when a token has leaked or you're decommissioning an IdP integration for good. ## Deprovisioning: deactivate vs. delete [#deprovisioning-deactivate-vs-delete] SCIM's standard offboarding action, and orboto's own delete route, behave differently - know which one your IdP sends before you rely on either: * **`PATCH` with `active=false`** (what a SCIM-compliant IdP sends on offboarding by default) **deactivates** the orboto user. They can no longer sign in, but every ticket, comment, and history item they ever touched stays intact and correctly attributed to them - nothing is deleted or reassigned. * **`DELETE` on a user** is a **soft-deactivate by default** - same effect as the PATCH case above, and the safe choice for most workspaces, since it never destroys history. An operator can flip this to a genuine hard row deletion instead (irreversible, removes the row outright) via a system-level setting - only do this if your compliance posture actually requires purging the row rather than deactivating it. In practice: unless you've deliberately switched to hard deletion, both paths are safe and reversible in effect (you can always reactivate a deactivated user manually in orboto) - what's NOT reversible is losing track of *when* something changed, which is why every SCIM action is logged (see Security below). ## Security [#security] * Tokens are workspace-scoped and stored hashed (SHA-256) - the plaintext only ever exists at the moment you mint or rotate it. * Each token has **per-second and per-day rate budgets**. Exceeding either returns HTTP `429` with a `Retry-After` header your IdP's provisioning engine should already respect (all four IdPs in the table above back off correctly on this). * Every SCIM operation - create, update, deactivate, delete, and any rejected/failed attempt - is recorded in the **sync history** under **Admin → SCIM provisioning**, with timestamp, operation, outcome, and the error detail on failures. This is the trail an auditor asks for when checking identity-management controls (the kind of evidence frameworks like SOC 2 or ISO 27001 expect for user lifecycle management), and it also feeds the workspace's general audit log and compliance export. {/* screenshot: the SCIM sync history table with a mix of successful and failed operations */} ### Filtering, sorting, and concurrency [#filtering-sorting-and-concurrency] For IdP engineers or anyone debugging a sync directly against the API: * The endpoint supports RFC 7644 filtering (`eq`, `ne`, `co`, `sw`, `ew`, `gt`, `ge`, `lt`, `le`, `pr`, combined with `and` / `or` / `not` and grouping), sorting, and attribute projection (`attributes` / `excludedAttributes` to shrink a response to just the fields you need). * Updates use **ETag optimistic concurrency**: a `PUT` or `PATCH` sends `If-Match` with the resource's current ETag, and the request is rejected if the resource changed since you last read it - this stops two concurrent updates from silently clobbering each other, which matters if you're scripting bulk changes outside the IdP's normal provisioning loop. ## Outbound lifecycle webhook (optional) [#outbound-lifecycle-webhook-optional] Beyond inbound provisioning, you can set an **outbound webhook URL** (same **Admin → SCIM provisioning** page) so orboto POSTs every lifecycle event (`user.created`, `user.updated`, `user.deactivated`, `user.deleted`) to an endpoint you control - useful for feeding a downstream audit or SIEM pipeline that wants to know about identity changes without polling orboto's API. Set a secret alongside the URL and every delivery carries an `X-Orboto-Signature` HMAC header you can verify, the same signing scheme orboto's other webhooks use (see the [API cookbook](/api-cli/cookbook#register-and-consume-a-webhook) for a worked verification example). ## Interplay with SSO [#interplay-with-sso] SCIM and [SSO](/admin/identity/sso) solve two different problems and are meant to run together, not as alternatives: * **SCIM** manages the **account lifecycle** - who has an orboto account at all, and what their profile fields say. * **SSO** manages **authentication** - how a user proves who they are when signing in. A user provisioned by SCIM has no orboto password; they sign in only through your IdP via SSO. If you run both, pair them with the "require 2FA for password accounts" policy scoped to just the accounts that genuinely remain on local passwords (service accounts, break-glass admins) - SCIM+SSO users don't need it since they never have a password to protect. ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A newly assigned IdP user never appears in orboto | Check the IdP's provisioning job/log first - most "nothing happened" cases are the IdP-side sync not having run yet (some IdPs provision on a schedule, not instantly) or a failed **Test connection** that was never actually fixed. Then check orboto's sync history for a rejected request with an error detail. | | Provisioning worked once, then stopped | Check whether the token expired, was rotated without updating the IdP, or was accidentally revoked. The sync history will show `401`s once the token stops being valid. | | Deactivating a user in the IdP doesn't lock them out of orboto | Confirm the IdP is actually configured to send the deprovisioning action on offboarding (some IdPs need this enabled explicitly, it isn't always the default), and check the sync history to see whether the `PATCH active=false` (or `DELETE`) request even arrived. | | Department / manager fields aren't syncing | Those come from the SCIM enterprise-user schema extension - confirm your IdP's provisioning app is actually configured to send that extension's attributes, not just the base user schema. | | Getting `429`s during a bulk provisioning run | You're exceeding the token's per-second or per-day rate budget. Most IdPs already throttle and retry on `429` automatically; if yours doesn't, space out the bulk assignment instead of assigning hundreds of users at once. | | A user was hard-deleted and you need their history back | If your workspace uses the default soft-deactivate behavior, nothing was actually destroyed - reactivate the user manually in orboto. If hard deletion was explicitly enabled, the row is genuinely gone; restoring it means restoring from a backup (see [Backups](/self-hosting/backups)). | # Symphony (/integrations/symphony) ## What Symphony is [#what-symphony-is] [Symphony](https://github.com/openai/symphony) is an open specification for orchestrating fleets of AI coding agents against a ticket tracker: it polls a tracker for workable issues, dispatches them to coding-agent sessions running in parallel, tracks each agent's turns and retries, and gates how far an agent gets before a human (or another check) has to approve. It was originally written against Linear as the tracker. orboto ships a **Symphony adapter**: a small set of REST endpoints, shaped exactly to what Symphony's tracker interface expects, plus a setup wizard that provisions everything a Symphony fork needs to point at your orboto workspace instead of Linear. If you're already running (or plan to run) a Symphony-based agent fleet and want it working your orboto backlog, this is the integration for that. If you instead want orboto's own **built-in** agent dispatch - no separate orchestrator to run - see [Work routing and fleets](/agents-ai/work-routing) and [Run an agent fleet](/agents-ai/run-a-fleet) instead. Symphony is for teams who are already standardized on the Symphony orchestration model and want orboto as its tracker backend. ## What it connects [#what-it-connects] A Symphony fork talks to orboto through a handful of endpoints under `/integrations/symphony/*`, each returning data already shaped as Symphony's normalized "Issue" - priority mapped to its 1-4 integer scale, labels lower-cased, blockers joined in, and a ready-to-use git branch name computed server-side: | Endpoint | Purpose | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /integrations/symphony/candidates` | The tracker's main poll: every issue in the given active-state categories, for one project or several. | | `POST /integrations/symphony/by-states` | Fetch issues by status category across one or more projects. | | `POST /integrations/symphony/by-ids` | Refresh a specific set of issues by ID (Symphony's reconciliation pass). | | `GET /integrations/symphony/branch-name/:ticketId` | A pure branch-name lookup for workspace-bootstrap hooks that don't need the full issue payload. | | `POST /integrations/symphony/agent-tool/query` | An optional agent-side tool: lets a running coding-agent turn run an OQL or JQL query against orboto mid-session, without routing back through the orchestrator. | | `POST /integrations/symphony/agent-events` | Session lifecycle events (started, turn completed, stalled, ...) an orchestrator can push so orboto's ticket view shows what an agent is doing. | Status is mapped by **category**, not by a project's per-status display name, since names get renamed ("To Do" → "Backlog") but categories are stable across every project and instance: | orboto category | Symphony `active_states` / `terminal_states` | | --------------- | -------------------------------------------- | | `todo` | active | | `in_progress` | active | | `in_review` | active | | `done` | terminal | | `wont_fix` | terminal | ### Field mapping [#field-mapping] | Symphony field | orboto source | Notes | | --------------------------- | ------------------------------------------------ | ----------------------------------------------------------- | | `id` | Ticket ID (UUID) | | | `identifier` | Ticket key | e.g. `ACME-42`. | | `title` | Ticket title | | | `description` | Ticket description | Markdown. | | `priority` | Ticket priority, mapped to an integer | `blocker=1`, `high=2`, `normal=3`, `low=4`, `trivial=4`. | | `state` | Status **category**, not name | `todo` / `in_progress` / `in_review` / `done` / `wont_fix`. | | `branch_name` | Computed from the ticket key + a slugified title | `null` if the project has no connected git repository. | | `url` | Built from the project key and ticket key | | | `labels` | Ticket labels, lower-cased | | | `blocked_by` | Open dependency tickets | Includes each blocker's id, key, and status category. | | `created_at` / `updated_at` | Ticket timestamps | ISO-8601. | ## Setup [#setup] Setup lives at **Admin → Integrations → Symphony** - a five-step guided wizard that replaces the manual work of creating a service account, assigning it to projects, minting an API key, and hand-writing a `WORKFLOW.md` config block. It's safe to re-run: it reuses an existing service account and its project memberships rather than creating duplicates. {/* screenshot: the Symphony Setup Wizard's five steps in Admin → Integrations */} 1. **Service account.** Pick an existing bot/service-account user, or create a new one by entering an email and display name (a sensible default is something like `symphony-bot@yourcompany.com`, "Symphony"). This is the identity every Symphony-dispatched action will be attributed to. 2. **Projects to authorize.** Check every project you want Symphony to see. Click **Apply** - the wizard adds the service account to each checked project (skipping ones it's already a member of) and shows a per-project result. 3. **API key.** Click **Mint API key**. The key is shown exactly once - copy it immediately into wherever your Symphony fork's deploy environment reads `ORBOTO_TOKEN` from. If you navigate away before copying it, mint a new one; the old one still works until you revoke it, but its plaintext is gone for good. 4. **WORKFLOW\.md snippet.** The wizard assembles a ready-to-use config block from what you picked in steps 1-3 - your instance's own address, the `ORBOTO_TOKEN` reference, and the project key(s) you authorized. **Copy** or **Download** it as `WORKFLOW.md` and drop it into your Symphony fork's repository (wherever your fork's config loader expects it). Replace the placeholder prompt body with your team's actual task instructions - what "done" means, branch naming, PR conventions - before you rely on it. 5. **Test connection.** Click **Run test** to confirm the service account can actually reach each authorized project through the adapter endpoints before you wire up a real Symphony deployment against it. ### Choosing a starting configuration [#choosing-a-starting-configuration] The generated `WORKFLOW.md` is a reasonable default for a small-to-medium team: moderate concurrency, a 30-second poll interval, generous per-turn budgets. Depending on how your team wants to run its fleet, you may want to tune it toward one of two other shapes once you have the wizard's output as a starting point: * **Higher concurrency, AI-heavy.** More agents running in parallel, longer turn budgets, and the push-triggered re-polling described below turned on so dispatch reacts to changes immediately instead of waiting for the next poll tick. * **Conservative, human-in-the-loop.** A single agent at a time, manual approval required on file changes and commands, and a longer turn timeout to absorb the time a human reviewer takes without tripping Symphony's stall detection. Both are the same `WORKFLOW.md` shape - the settings your Symphony fork's config schema exposes for concurrency, approval policy, and timeouts - just tuned differently. Start from what the wizard generates and adjust from there. ## Usage [#usage] Once your Symphony fork is deployed with the generated config: * On its normal poll interval (30 seconds by default), Symphony fetches candidate issues from `GET /integrations/symphony/candidates` and dispatches agents against whichever ones fit its concurrency limits. * **Push-triggered re-polling** (optional): subscribe a webhook to the `symphony.candidates_changed` event (**Admin → Webhooks → Add**, event filter set to just that event) so your fork can flag its candidate list dirty and re-poll immediately on a status, assignee, or priority change, instead of waiting out the poll interval. This event is also available as an [n8n Trigger event](/integrations/n8n-reference#orboto-trigger-events) if you'd rather react to it from a workflow than build the listener into your fork directly. * As agents work, each ticket they touch shows an **Agent** tab in its ticket detail view (once at least one lifecycle event has been recorded for it) with the session's activity - started, each turn, and how it ended - so a human glancing at the ticket sees what an agent already did without leaving orboto. * Symphony's own dispatch loop decides claiming, status moves, comments, and PR links through the coding agent's normal tool use - the adapter only supplies **read** access to issue data plus the small write surface for activity events above. It never mutates a ticket's status or content directly through these endpoints itself. ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Test connection fails for a project | The service account isn't actually a member of that project - re-run step 2 of the wizard and confirm it's checked, or check the project wasn't renamed/archived since. | | Candidates endpoint returns nothing | Confirm your fork's `active_states` uses orboto's category values (`todo`, `in_progress`, `in_review`) and not a project's display names - a category value like `todo` always matches; a display name like "To Do" only matches if nobody ever renamed the status. | | Symphony never claims a ticket it should see | Check the ticket isn't private with the service account excluded from its access list, and that its project is checked in the wizard's authorized-projects step. | | Lost the API key before copying it | Mint a fresh one from the wizard's step 3 - the old key keeps working until you separately revoke it from **Admin → API keys**. | | `symphony.candidates_changed` webhook never fires | Confirm the webhook is active under **Admin → Webhooks** and its event filter includes that event - it's opt-in, so a webhook that doesn't list it is unaffected by ticket changes. | | Branch name comes back `null` | The ticket's project has no connected git repository - see [Git integration](/integrations#code-repositories-git). | # Sending domains and DNS (/mail/domains) Every email you send has a `from` address, and orboto Mail only sends on behalf of domains that are verified on your account. Verification is what lets receiving mail servers (Gmail, Outlook, ...) confirm a message claiming to be from `you@yourdomain.example` really was authorized by that domain's owner - without it, transactional email either gets rejected outright or lands in spam. This page explains what verification means and walks through adding your own domain; see [Sending email](/mail/sending#error-responses) for the specific error a send returns when the `from` domain isn't ready yet. ## If your app runs as an orboto SaaS workspace [#if-your-app-runs-as-an-orboto-saas-workspace] Nothing to do here. An orboto SaaS workspace connected to orboto Mail gets a dedicated subdomain (`.orbo.to`) provisioned and DKIM-verified automatically the moment you connect - see [Using orboto Mail from an orboto workspace](/mail/orboto-integration). SPF and DMARC are configured once at the `orbo.to` root and inherited by every workspace subdomain, so there's no DNS work on your side at all. The rest of this page is for a **custom domain** - your own domain name, used by a standalone application or a self-hosted orboto instance. ## Add a custom domain [#add-a-custom-domain] ```bash curl https://mail.orboto.io/api/v1/sender-domains \ -X POST \ -H "Authorization: Bearer oms_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "domain": "yourdomain.example" }' ``` The response includes everything you need to publish: ```json { "id": "b7e6c1a0-...", "domain": "yourdomain.example", "dkimSelector": "orboto", "verificationStatus": "pending", "dkimRecords": [ { "name": "orboto._domainkey.yourdomain.example", "type": "TXT", "value": "v=DKIM1; k=rsa; p=" } ], "spfRequired": "v=spf1 include:spf.orboto.io ~all", "dmarcRecommended": "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@dmarc.orboto.io; ruf=mailto:dmarc-reports@dmarc.orboto.io; aspf=r; adkim=r", "mailFromRecords": [ { "name": "bounces.yourdomain.example", "type": "MX", "value": "feedback-smtp.eu-central-1.amazonses.com", "priority": 10 }, { "name": "bounces.yourdomain.example", "type": "TXT", "value": "v=spf1 include:amazonses.com ~all" } ] } ``` orboto Mail generates a DKIM keypair for you (this is "BYODKIM" - Bring-Your-Own-DKIM: orboto Mail owns the key, you publish only the public half) and keeps the private key encrypted at rest. You never handle key material yourself. ## The DNS records to publish [#the-dns-records-to-publish] | Record | Type | Name | Value | Purpose | | ----------------------- | ---- | -------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | DKIM | TXT | `orboto._domainkey.yourdomain.example` | from `dkimRecords[0].value` above | Cryptographically signs every message so receivers can verify it wasn't altered in transit and really came from you. | | SPF | TXT | `yourdomain.example` (root) | `v=spf1 include:spf.orboto.io ~all` | Authorizes orboto Mail's sending infrastructure to send as your domain. If you already have an SPF record for other mail (e.g. Google Workspace), add `include:spf.orboto.io` to the existing record rather than creating a second TXT record - a domain can only have one SPF record. | | DMARC | TXT | `_dmarc.yourdomain.example` | from `dmarcRecommended` above | Tells receivers what to do with mail that fails SPF/DKIM, and where to send aggregate reports. Recommended, not required for `verified` status. | | MAIL FROM (Return-Path) | MX | `bounces.yourdomain.example` | `feedback-smtp.eu-central-1.amazonses.com` (priority `10`) | Points bounce handling at a subdomain you own instead of a shared AWS address, which is what makes SPF alignment (and therefore full DMARC pass) possible. | | MAIL FROM (Return-Path) | TXT | `bounces.yourdomain.example` | `v=spf1 include:amazonses.com ~all` | SPF for the bounce subdomain above. | Some DNS providers (notably Route 53 and other strict resolvers) reject a single TXT value longer than 255 bytes. If a record in the API response includes a `valueChunks` array alongside `value`, use the chunks - each is a separate quoted string inside the same TXT record. Providers like Cloudflare and Google Cloud DNS accept the long single `value` directly and never need this. If your domain is on Cloudflare, `POST /v1/sender-domains/:id/cloudflare-auto-setup` (with a scoped Cloudflare API token in the body) creates every record above for you automatically instead of pasting them by hand - `GET /v1/sender-domains/:id/cloudflare-detect` tells you upfront whether the domain is on Cloudflare at all. Both are also available as buttons in the dashboard. {/* screenshot: account.orboto.io/mail/domains - add-domain flow showing the DNS records to publish + Cloudflare auto-setup button */} ## Verifying [#verifying] DNS changes take time to propagate. Once the records are published, trigger a check: ```bash curl https://mail.orboto.io/api/v1/sender-domains/b7e6c1a0-.../verify \ -X POST \ -H "Authorization: Bearer oms_live_your_key_here" ``` `verificationStatus` moves through these states: | Status | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `pending` | Records not detected yet. Re-check after DNS propagation (a few minutes to a few hours, depending on your provider and TTLs). | | `verified` | DKIM confirmed. Sending from this domain now works. | | `temporary-failure` | A transient error on the verification side. Re-check again shortly - no action needed on your part. | | `failed` | Records were published, then removed or changed after the domain had verified. Sending is blocked until you republish and re-verify. | Only `verified` domains can be used as the `from` address on a send - see the `from_domain_not_verified` error on the [sending page](/mail/sending#error-responses). ## Removing a domain [#removing-a-domain] ```bash curl https://mail.orboto.io/api/v1/sender-domains/b7e6c1a0-... \ -X DELETE \ -H "Authorization: Bearer oms_live_your_key_here" ``` This deregisters the sending identity on orboto Mail's side. It does not remove the DNS records from your provider - do that separately if you no longer want them published. ## Troubleshooting [#troubleshooting] | Symptom | Fix | | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Still `pending` after 24 hours | Double-check the DKIM TXT record's name and value were copied exactly - a trailing dot, wrong selector, or truncated `value` (see the `valueChunks` note above) is the usual cause. Some registrars also silently drop long TXT values without an error - try `valueChunks` even if you initially used `value`. | | `verified`, then dropped to `failed` | Something changed the DNS record after it verified - a provider migration, a DNS zone reset, or an accidental edit. Republish the exact record from a fresh `GET /v1/sender-domains/:id` call (don't reuse an old copy) and verify again. | | `409 reserved_domain` | The domain (or a subdomain of it) is reserved for orboto's own infrastructure and can't be claimed by a customer account. | | `409 domain_already_added` | The domain is already on your account - the error includes the existing domain's id; call `/verify` on that id instead of creating a new one. | | I have an existing SPF record for other email (Google Workspace, etc.) | Don't add a second SPF TXT record - a domain can only have one. Merge `include:spf.orboto.io` into your existing SPF record's `include:` list instead. | | DMARC reports still show failures after `verified` | Confirm the MAIL FROM records were also published - DKIM alone gives DMARC pass, but full SPF alignment (used as failover if the primary region's DKIM check has a transient issue) needs the `bounces.yourdomain.example` MX + TXT pair too. | # orboto Mail (/mail) Transactional email is the mail your application sends because something happened - a signup confirmation, a password reset, an invoice, a workflow notification. It is different from marketing email (newsletters, campaigns) in one important way: each message is triggered by one user's action and that user expects to receive it. Getting it delivered reliably requires infrastructure most teams would rather not build themselves: a sending service with a good reputation, DKIM/SPF/DMARC configured correctly, bounce and complaint handling, and a record of what was sent. **orboto Mail** (internally "OMS") is that infrastructure, run for you. It is a standalone product - any application can send through it, with or without an orboto workspace - built on Amazon SES with EU-only data residency (Frankfurt + Dublin), and it plugs directly into an orboto workspace's outbound email (invites, notifications, password resets) with a single click. See [Using orboto Mail from an orboto workspace](/mail/orboto-integration) for that specific path; the rest of this section documents orboto Mail as a product on its own. ## What you get [#what-you-get] * **A single send API.** `POST /v1/send` takes a from-address, a recipient, and either raw HTML/text or a server-side template, and hands the message to SES across two EU regions with automatic failover. See [Sending email](/mail/sending). * **Batch sending.** `POST /v1/send/batch` sends up to 100 messages in one call instead of looping client-side. * **Server-side templates.** Store a subject + HTML/text body with a JSON-schema-validated set of variables, then send by referencing the template's id instead of re-sending the markup every time. See [Templates](/mail/templates). * **Verified sending domains.** Add your own domain, publish the DNS records orboto Mail gives you, and send as `you@yourdomain.example` with DKIM, SPF, and DMARC all correctly aligned. See [Sending domains and DNS](/mail/domains). * **Suppression list + delivery events.** Hard bounces and spam complaints are automatically added to a per-account suppression list and excluded from future sends; every bounce, complaint, delivery, and quota event can be pushed to your own webhook endpoint. See [Suppression and delivery events](/mail/suppression-and-events). * **A monthly quota with clear failure modes.** Every account has a monthly sending quota; `GET /v1/quota` reports current usage, and a send that would exceed the quota fails with a specific, actionable reason instead of a generic error. * **Attachments, CC/BCC, and tags.** Up to 20 attachments and 30 MB per message, up to 50 CC and 50 BCC recipients, and a free-form tag bag on every send for your own reporting. * **Optional open tracking.** Per sending domain, you can opt in to a tracking pixel that records opens - off by default, since it touches recipient privacy. ## Two ways to use it [#two-ways-to-use-it] | Path | Who it's for | Where | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | **Direct API / SDK** | Any application, orboto workspace or not | `mail.orboto.io` - REST API, described in this section | | **orboto workspace provider** | An orboto workspace that wants its own outbound email (invites, notifications, resets) to go through orboto Mail | Admin → System Settings → Email delivery, inside your orboto instance | Both paths hit the same API and the same account - the workspace integration is a connect-once shortcut, not a different product. ## Account and dashboard [#account-and-dashboard] There is no dashboard on `mail.orboto.io` itself - that host is the API endpoint plus this documentation. Your account, billing, API keys, sending domains, templates, suppression list, and webhook subscriptions are all managed at **account.orboto.io/mail** once you've signed up. {/* screenshot: account.orboto.io/mail overview dashboard - usage this month, quick links to domains/templates/suppression */} orboto Mail is a **cloud-only** service - there is no self-hosted deployment. If you self-host orboto itself, you still reach orboto Mail over the public API exactly like any other application. ## Troubleshooting [#troubleshooting] | Symptom | Likely cause | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | I don't know where to sign up or see usage | There's no dashboard on `mail.orboto.io`. Go to `account.orboto.io/mail` for account, billing, domains, and API keys. | | My app already sends email through my orboto workspace - do I need to sign up separately? | No. Connecting a workspace to orboto Mail (see [orboto integration](/mail/orboto-integration)) provisions an account and a sending subdomain for you automatically. | | I want to send marketing/newsletter email through this | orboto Mail is transactional-only (triggered by a single user action). It is not built or priced for bulk marketing sends. | # Using orboto Mail from an orboto workspace (/mail/orboto-integration) An orboto workspace sends its own transactional email: invitations, in-app notification emails, password resets, digest summaries. That outbound mail needs a provider configured somewhere, and **orboto Mail Service** is the recommended one - it's EU-hosted, requires no separate signup, and (for a cloud SaaS workspace) provisions a verified sending subdomain automatically. This page covers connecting an orboto workspace to it; for the standalone product API itself, start at [orboto Mail](/mail). ## Connecting [#connecting] Go to **Admin → System Settings → Email delivery** and set **Provider** to **"orboto Mail Service - recommended"**. A connection card appears below the field group with two ways to finish setup: {/* screenshot: Admin -> System Settings -> Email delivery, Provider dropdown set to "orboto Mail Service", connection card showing the "Connect to orboto Mail" button */} ### One-click connect (recommended) [#one-click-connect-recommended] Click **Connect to orboto Mail**. You're redirected to `account.orboto.io/mail` to approve the connection; when you come back, your workspace has: * an orboto Mail account (created automatically if you didn't already have one), * an API key issued and stored for you - you never see or handle the raw key, * a verified sending domain (`.orbo.to`) with DKIM already configured, so mail starts flowing with zero DNS work, * sending capacity billed as part of your existing orboto subscription rather than a separate account to manage. The card then shows **Connected**, the resolved from-address, and the OMS endpoint in use. A **Disconnect** button revokes the connection on both sides and clears the local configuration - outbound email stops working until you configure a provider again. ### Manual API key [#manual-api-key] If you'd rather not use the OAuth flow - common for self-hosted instances, or when an operator issues keys centrally - fill in the fields directly instead of clicking Connect: | Field | Value | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **OMS API key** | An `oms_live_...` key from [account.orboto.io/mail/api-keys](https://account.orboto.io) (see [Sending email](/mail/sending#base-url-and-authentication) for how keys work), or one issued by your operator. | | **From address** | A verified sender on that account, e.g. `orboto ` - see [Sending domains and DNS](/mail/domains) to verify one. | | **OMS base URL** *(optional)* | Defaults to `https://mail.orboto.io`. Only change this for a self-hosted or staging orboto Mail deployment. | A connection made this way shows as "Manually pasted API key (no connection-id)" on the card - disconnecting only clears the local configuration, it doesn't notify or revoke anything on the orboto Mail side (there's nothing to revoke: you control the key directly, so revoke it yourself at `account.orboto.io/mail/api-keys` if needed). ## Verifying it works [#verifying-it-works] Use the **Send test email** field at the bottom of the Email delivery section: enter an address and send. A successful test confirms the whole path - provider selection, credentials, and sender-domain verification - in one click, without needing to trigger a real invitation or notification. {/* screenshot: Email delivery section, "Send test email" row with an address field and result message */} ## How your workspace picks a provider [#how-your-workspace-picks-a-provider] Email delivery isn't limited to orboto Mail - System Settings also supports Resend and a plain SMTP server, and picks between them in this order: an explicit **Provider** selection always wins; if it's unset, your workspace prefers orboto Mail if an API key is present, then Resend, then SMTP. If you're running on orboto's managed cloud, your workspace may already have orboto Mail configured for you by the operator as part of provisioning - the Email delivery section still shows and lets you override it. ## Troubleshooting [#troubleshooting] | Symptom | Fix | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Connect to orboto Mail** button does nothing or errors | Check your workspace can reach `account.orboto.io` (outbound HTTPS), and check the system logs for the connect attempt. You can always fall back to pasting an API key manually while investigating. | | Test email fails with a from-domain error | Your **From address** field's domain isn't verified yet on the connected orboto Mail account. If you connected via OAuth, the `.orbo.to` domain should already be verified - check the from-address actually uses that domain. If you pasted a manual key against your own custom domain, verify it first - see [Sending domains and DNS](/mail/domains). | | Test email fails with an authentication error | The API key is invalid or was revoked - most commonly because the connection was disconnected from the `account.orboto.io/mail` side. Reconnect, or paste a fresh key. | | I disconnected by mistake and workspace email stopped | Outbound email has no automatic fallback provider - reconnect (or select a different provider) in Email delivery. Nothing queued during the outage is lost; it simply wasn't sent, so time-sensitive mail like password resets should be re-triggered by the affected user. | | I want to see exactly what was sent | `account.orboto.io/mail/sends` shows your connected account's full send history, or query `GET /v1/sends` directly - see [Sending email](/mail/sending#viewing-what-was-sent). | # Sending email (/mail/sending) This page covers the direct REST API. Everything here works the same whether or not your application is an orboto workspace. ## Base URL and authentication [#base-url-and-authentication] ``` https://mail.orboto.io/api/v1/* ``` Every request carries a Bearer token: ``` Authorization: Bearer oms_live_ ``` Keys look like `oms_live_...` for production sending or `oms_test_...` for a sandbox key, and are managed at **account.orboto.io/mail/api-keys** (create, name, rotate, revoke). {/* screenshot: account.orboto.io/mail/api-keys - list + create dialog showing the plaintext key shown once */} The full secret is shown exactly once, at creation - orboto Mail only ever stores a salted hash of it, so a lost key can't be recovered, only rotated. If your orboto workspace is connected to orboto Mail (see [orboto integration](/mail/orboto-integration)), a key is issued for you automatically and you don't need to visit the dashboard for this step. ## Send a single email [#send-a-single-email] ```bash curl https://mail.orboto.io/api/v1/send \ -X POST \ -H "Authorization: Bearer oms_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Support ", "to": "user@example.com", "subject": "Welcome to Acme", "html": "

Welcome!

Thanks for signing up.

", "text": "Welcome! Thanks for signing up.", "tags": { "workflow": "welcome" } }' ``` A successful send returns `200`: ```json { "messageId": "0100019a-1b2c-4d5e-8f90-abcdef123456", "status": "queued", "overage": false, "remainingQuota": { "current": 48, "total": 10000, "resetAt": "2026-09-01T00:00:00.000Z", "percentUsed": 0.0048, "softWarnAt": 0.8, "softWarnTriggered": false, "dailyCap": null, "creditBalance": 0 } } ``` `status` is always `"queued"` on success - orboto Mail hands the message to SES synchronously and returns immediately; delivery, bounce, and complaint outcomes arrive later as [delivery events](/mail/suppression-and-events). `remainingQuota` reports the account's quota state after this send so you can decide client-side whether to slow down before the next one. ### Request fields [#request-fields] | Field | Required | Notes | | -------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `from` | yes | Must be on a [verified sending domain](/mail/domains) for your account. Accepts a bare address or `Display Name
`. | | `to` | yes | A single recipient address. | | `cc`, `bcc` | no | Arrays, up to 50 addresses each. Bcc recipients never appear in headers seen by anyone else on the message. | | `subject` | conditionally | Required unless `templateId` supplies one. Max 998 characters (RFC 5322 line length). | | `html`, `text` | conditionally | At least one of `html`, `text`, or `templateId` is required. `html` and `text` cannot be combined with `templateId` in the same call - pick raw content or a template. | | `templateId` + `variables` | no | Send via a [stored template](/mail/templates) instead of raw content. | | `tags` | no | A flat object of string keys/values (max 256 chars each), stored on the send record for your own filtering/reporting. | | `attachments` | no | Array, max 20 items. See below. | ### Attachments [#attachments] ```json { "attachments": [ { "filename": "invoice.pdf", "contentType": "application/pdf", "content": "" } ] } ``` Up to 20 attachments per send, base64-encoded in the JSON body, with a combined decoded size limit of 30 MB. Each entry also accepts an optional `contentId` for inline references (e.g. an image referenced from the HTML body via `cid:`). ## Send a batch [#send-a-batch] ```bash curl https://mail.orboto.io/api/v1/send/batch \ -X POST \ -H "Authorization: Bearer oms_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "from": "support@yourdomain.example", "to": "a@example.com", "subject": "Hi A", "html": "

Hi A

" }, { "from": "support@yourdomain.example", "to": "b@example.com", "subject": "Hi B", "html": "

Hi B

" } ] }' ``` `POST /v1/send/batch` runs every message through the same validation, quota, and send pipeline as a single send, up to 100 messages per call. It always returns `200`, even if every message failed - read `summary` and each item's `ok` flag rather than the HTTP status: ```json { "results": [ { "index": 0, "ok": true, "messageId": "...", "status": "queued", "overage": false }, { "index": 1, "ok": false, "error": "recipient_suppressed", "message": "..." } ], "remainingQuota": { "...": "last snapshot after the batch" }, "summary": { "queued": 1, "rejected": 0, "suppressed": 1, "skipped": 0 } } ``` Messages are processed in array order. If the account's quota runs out partway through the batch, every remaining item comes back with `quotaSkipped: true` instead of being retried - the quota won't refill mid-batch, so there's no point checking again. ## Checking your quota without sending [#checking-your-quota-without-sending] ```bash curl https://mail.orboto.io/api/v1/quota \ -H "Authorization: Bearer oms_live_your_key_here" ``` Returns the same `remainingQuota` shape shown above, wrapped in `{ "quota": { ... } }` - useful before kicking off a large batch, or for your own usage dashboards. ## Viewing what was sent [#viewing-what-was-sent] ```bash curl "https://mail.orboto.io/api/v1/sends?limit=20&status=bounced" \ -H "Authorization: Bearer oms_live_your_key_here" ``` `GET /v1/sends` lists your account's sends, most-recent first, cursor- paginated via `?cursor=&limit=<1-100>` (`nextCursor` in the response feeds the next call). Filter with `status` (`queued`/`delivered`/`bounced`/`complained`/`rejected`), `region` (`eu-central-1`/`eu-west-1`), or `since` (ISO timestamp). `GET /v1/sends/:id` returns one send's full detail, including open-tracking counters if enabled on the sending domain. The same history is browsable at **account.orboto.io/mail/sends**. {/* screenshot: account.orboto.io/mail/sends - filterable send history table with status chips */} ## Error responses [#error-responses] Every rejection returns a JSON body with `error`, `reason`, and a human-readable `message`. The reason values you'll actually hit: | HTTP | `reason` | Meaning | | ---- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `from_domain_not_authorized` | The `from` address's domain isn't on this account at all. Add it - see [Sending domains](/mail/domains). | | 400 | `from_domain_not_verified` | The domain is on the account but its DNS records aren't verified yet. | | 400 | `subject_required` | No `subject` given and no `templateId` to supply one. | | 400 | `template_variable_validation` | `variables` didn't satisfy the template's schema. | | 422 | `recipient_suppressed` | The recipient is on your [suppression list](/mail/suppression-and-events) (a previous hard bounce, complaint, or manual add). The send is skipped and does **not** consume quota. | | 402 | `quota_exhausted_*` / `payment_required` | Monthly quota (and any overage allowance) is used up. `message` explains the specific reason and what to do next. | | 503 | `send_failed` / `wallet_unavailable` | A transient upstream problem (both SES regions unreachable, or - for above-quota sends - the billing wallet couldn't be reached). Safe to retry. | ## Troubleshooting [#troubleshooting] | Symptom | Fix | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401 token_missing` / `token_malformed` | Check the `Authorization: Bearer oms_live_...` header is present and the key wasn't truncated when copied. | | `401 token_revoked` | The key was revoked (manually, or because the OAuth connection it came from was disconnected). Issue a new one. | | `400 from_domain_not_authorized` | The `from` address's domain has to be added and verified first - see [Sending domains and DNS](/mail/domains). | | `422 recipient_suppressed` on an address you expect to be fine | Check `GET /v1/suppression/:email` - a past bounce or complaint may have added it. Remove it via `DELETE /v1/suppression/:email` if it was a false positive. | | Send succeeds (`200 queued`) but the recipient never got it | Query `GET /v1/sends/:id` a little later for the delivered/bounced outcome, or subscribe a [webhook](/mail/suppression-and-events) to get notified the moment it changes. | | Getting `404`/`405` on every call | Almost always a missing or duplicated `/api` segment - the correct base is `https://mail.orboto.io/api/v1/...`. | # Suppression and delivery events (/mail/suppression-and-events) ## The suppression list [#the-suppression-list] When a recipient's mail server permanently rejects a message ("this mailbox doesn't exist") or the recipient marks it as spam, orboto Mail automatically adds that address to your account's **suppression list** and stops sending to it. This protects your sending reputation - mail providers penalize senders who keep hitting invalid addresses or generating complaints, and one bad list can hurt deliverability for every other recipient too. Every send checks the suppression list first. A suppressed recipient gets skipped with a `422 recipient_suppressed` response (see [Sending email](/mail/sending#error-responses)) and, importantly, **does not consume your quota** - you're never charged for a send that was blocked. | Source | When it's added | | ------------- | ---------------------------------------------------------------------------------------------------------------- | | `hard-bounce` | The receiving server permanently rejected the message (invalid address, domain doesn't exist, mailbox disabled). | | `complaint` | The recipient marked the message as spam/junk. | | `manual` | You added it yourself - via the API or the dashboard - typically for an explicit unsubscribe request. | ### Managing entries [#managing-entries] ```bash # Check one address curl https://mail.orboto.io/api/v1/suppression/user@example.com \ -H "Authorization: Bearer oms_live_your_key_here" # Add one manually (e.g. an explicit opt-out request) curl https://mail.orboto.io/api/v1/suppression \ -X POST \ -H "Authorization: Bearer oms_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "reason": "manual" }' # Remove a false positive curl https://mail.orboto.io/api/v1/suppression/user@example.com \ -X DELETE \ -H "Authorization: Bearer oms_live_your_key_here" # List, paginated, optionally filtered by reason curl "https://mail.orboto.io/api/v1/suppression?limit=20&reason=hard-bounce" \ -H "Authorization: Bearer oms_live_your_key_here" ``` Adding an address that's already suppressed is a no-op (not an error) - safe to call repeatedly. Removing an address that was auto-added by a hard bounce is appropriate only when you're confident the underlying problem is fixed (a typo the recipient corrected, a mailbox that was temporarily over quota and is now active again); otherwise the next send will very likely bounce again and re-add it. {/* screenshot: account.orboto.io/mail/suppression - filterable list with reason badges and a remove action */} ## Delivery events (webhooks) [#delivery-events-webhooks] Suppression handles the send-time consequence of a bad address automatically. If you want to *react* to what happens to a message after it's sent - update a user's profile when their email bounces, alert your team when quota is running low - subscribe a webhook. ```bash curl https://mail.orboto.io/api/v1/webhooks \ -X POST \ -H "Authorization: Bearer oms_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.example.com/webhooks/orboto-mail", "label": "production", "eventFilters": ["bounce.permanent", "complaint", "delivery"] }' ``` The response includes `secret` - a signing secret shown **exactly once**. Omit `eventFilters` (or pass an empty array) to receive every event type instead of a subset. ### Event types [#event-types] | Event | Fires when | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `delivery` | The recipient's mail server accepted the message. | | `bounce.permanent` | A hard bounce - the address is also added to your suppression list. | | `bounce.transient` | A soft bounce (mailbox full, server temporarily unavailable) - not added to suppression; the address may succeed on a later send. | | `complaint` | The recipient marked the message as spam - also added to your suppression list. | | `email.opened` | The recipient's mail client loaded the tracking pixel, if [open tracking](/mail/domains) is enabled on the sending domain. Fires once, on the first open. | | `quota.soft-warn-80` / `quota.soft-warn-95` | Monthly usage crossed 80% / 95% of your base quota. | | `quota.exhausted-base` | Base monthly quota is used up. | | `quota.exhausted-cap` | Above-quota (overage) usage hit its account cap too. | Each delivery is POSTed as JSON: ```json { "event": "bounce.permanent", "timestamp": "2026-08-30T12:00:00.000Z", "data": { "messageId": "...", "to": "user@example.com", "bounceType": "Permanent", "suppressionAdded": true } } ``` with three headers on every request: ``` X-OMS-Webhook-Id: X-OMS-Event: bounce.permanent X-OMS-Signature: t=1735000000, v1= ``` ### Verifying the signature [#verifying-the-signature] `v1` is `HMAC_SHA256(your_signing_secret, ".")`, hex-encoded. Recompute it on your side and compare: ```js import { createHmac, timingSafeEqual } from 'node:crypto'; function isValidOmsWebhook(secret, signatureHeader, rawBody, toleranceSeconds = 300) { const tMatch = /t=(\d+)/.exec(signatureHeader); const vMatch = /v1=([0-9a-f]+)/.exec(signatureHeader); if (!tMatch || !vMatch) return false; const t = Number(tMatch[1]); if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false; const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const got = vMatch[1]; return got.length === expected.length && timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex')); } ``` Use the **raw** request body (before any JSON parsing) when computing the HMAC - re-serializing a parsed object can produce different bytes (key order, whitespace) and break the signature check. The `t=` value also doubles as replay protection: reject anything older than a few minutes, as the example above does. ### Managing subscriptions [#managing-subscriptions] | Method + path | Purpose | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/webhooks` | List your subscriptions (signing secret never included). | | `GET /v1/webhooks/:id` | Get one. | | `PATCH /v1/webhooks/:id` | Update `url`, `label`, `eventFilters`, or `enabled` (set `enabled: false` to pause deliveries without deleting the subscription). | | `DELETE /v1/webhooks/:id` | Remove it. | | `POST /v1/webhooks/:id/rotate-secret` | Issue a new signing secret - the old one stops working immediately. Returns the new plaintext secret once. | `url` must be `https://` (plain `http://` is only accepted for `localhost`/`127.0.0.1`, for local development). orboto Mail retries a failing delivery with backoff; `GET /v1/webhooks/:id` reports `lastSuccessAt`, `lastFailureAt`, and `lastFailureReason` so you can see whether deliveries are actually landing. ## Troubleshooting [#troubleshooting] | Symptom | Fix | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A known-good address keeps getting `recipient_suppressed` | It was likely added by an earlier bounce or complaint. Check `GET /v1/suppression/:email` for the `reason` and `addedAt`, then `DELETE` it if the underlying issue is genuinely fixed. | | My webhook isn't receiving anything | Confirm `enabled` is `true` and the event actually happened - `eventFilters` defaults to nothing sent unless you either list the event explicitly or leave the array empty to receive everything. Check `lastFailureReason` on `GET /v1/webhooks/:id` for delivery errors on your endpoint. | | Signature verification always fails | Almost always caused by re-serializing the JSON body before hashing it - hash the exact raw bytes you received, not a re-`JSON.stringify`'d copy. | | I rotated my secret and now nothing verifies | Expected - a rotation immediately invalidates the previous secret for every future delivery. Update your verification code with the new `secret` returned from the rotate call. | | I want to stop bounces from generating a webhook call but keep suppression | Not separable per-event today - suppression and its webhook fire together. `PATCH` your subscription's `eventFilters` to drop `bounce.permanent`/`complaint` if you only want the suppression side-effect without the notification. | # Templates (/mail/templates) A **template** is a subject and body stored on your orboto Mail account instead of inside your application code. This matters for two reasons: you can update the wording of a transactional email (a welcome message, a password reset) without a deploy, and a template can validate its own variables so a caller that forgets one gets a clear error instead of a half-rendered email reaching a customer. ## Create a template [#create-a-template] ```bash curl https://mail.orboto.io/api/v1/templates \ -X POST \ -H "Authorization: Bearer oms_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "welcome", "subject": "Welcome to Acme, {{name}}!", "bodyHtml": "

Hi {{name}}

Activate your account: click here.

", "bodyText": "Hi {{name}}, activate your account: {{activationUrl}}", "variablesSchema": { "type": "object", "required": ["name", "activationUrl"], "properties": { "name": { "type": "string" }, "activationUrl": { "type": "string", "format": "uri" } } } }' ``` Placeholders use `{{variableName}}` syntax in both `subject` and the body fields. An unknown placeholder (a typo, or a variable you forgot to pass) renders as an empty string rather than failing the send - so a mistake shows up as missing text in the delivered email, not a crash. Values substituted into `bodyHtml` are HTML-escaped automatically, so a variable containing `