# Logu > Logu is where software agents reach a human. Agents ask, the human decides. An agent sends an > **ask** when it needs a decision (approve a deploy, pick an option), an **event** when something > happened, and a **metric** when it reports a number. The human gets them by phone push, browser > notifications, email, Telegram, Slack and WhatsApp, and decides from wherever they are. Agents never > decide. This file is for agents and the people wiring them up. The API is `https://api.logu.dev`. The full version, with the agent guide and every error code, is `https://api.logu.dev/llms-full.txt`. The complete contract is OpenAPI 3.1 at `https://api.logu.dev/openapi.json` (every operation, schema, example and error code; `x-agent-hint` on each operation says when to call it and whether it blocks), readable at `https://logu.dev/docs/api`. Guides and quickstarts: `https://logu.dev/docs`. ## Auth Every request sends an API key: `Authorization: Bearer lg_…`. Everything the agent sends belongs to its workspace: that workspace's Owners and Deciders get its asks. An API key never sees another workspace. `GET /v1/me` says which agent and workspace a key is, and its limits. Test mode: a test API key (`lg_test_…`; Agents → "Add a test API key", `logu login --test`, or `POST /v1/agents/{id}/api-keys {"test":true}`) works the same, but its asks, events and metrics are marked `test: true`, shown in the app with a Test badge and never delivered to anyone's phone (no push, email, Telegram, Slack or WhatsApp). A human still decides test asks in the app (or they time out), and callbacks are sent. `GET /v1/me` says it: `apiKey.test`. Use one while building an integration. Getting an API key without copying one by hand (on a machine with a human at hand): `npx -y @logu/cli install claude-code` (Claude Code), `npx -y @logu/cli install codex` (OpenAI Codex) or `npx -y @logu/cli login` (any script). The CLI shows a code like `WDJB-MJHT` and opens `https://logu.dev/connect?code=WDJB-MJHT`; the human picks a new agent or an existing one (e.g. "Codex") and clicks Connect, and the CLI saves a new API key for it in `~/.config/logu/config.json` (mode 0600) as a profile: `claude-code`, `codex` or `default` (`--profile `, `LOGU_PROFILE`), so each integration is its own agent. The CLI, the hooks and `@logu/sdk` read it when `LOGU_API_KEY` isn't set. Any client can do the same (RFC 8628 style, no credentials): `POST /v1/device/code` `{client_name, client_kind}` (`claude-code`, `codex` or `cli`, optional) → `{device_code, user_code, verification_uri_complete, interval}`; show the human the code and URL, then `POST /v1/device/token {"device_code"}` every `interval` seconds until it returns `{api_key, agent, workspace}` (once). While waiting it answers 400 `authorization_pending` (or `slow_down`: add 5 s); stop on 403 `access_denied` or 400 `expired_token` (10 minutes). Never show or log `device_code` or the API key. For CI and servers, create an agent in the app (Agents page) and set `LOGU_API_KEY`. ## Asks: ask, then wait for the decision ```sh # 1. Ask (201). Approve/Decline by default; fail closed with defaultOption. curl -s https://api.logu.dev/v1/asks \ -H "Authorization: Bearer $LOGU_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: deploy-4821" \ -d '{"project":"shop","title":"Deploy v1.4 to production?","body":"3 commits since v1.3", "timeoutSeconds":3600,"defaultOption":"decline"}' # → {"id":"ask_…","status":"waiting","expiresAt":"…",…} # 2. Wait: long-poll up to 50 s per call; repeat while status is "waiting" curl -s "https://api.logu.dev/v1/asks/ask_…/wait?timeout=50" \ -H "Authorization: Bearer $LOGU_API_KEY" # → {"status":"decided","decision":{"optionId":"approve","decidedBy":{"name":"Ana"},…},…} ``` The wait loop: ``` loop: ask = GET /v1/asks/{id}/wait?timeout=50 if ask.status != "waiting": break proceed only if ask.status == "decided" and ask.decision.optionId == "approve" ``` - States: `waiting` → `decided` (a human chose; or the default applied on timeout, with `decision.decidedVia: "timeout"`; or an Owner's rule decided it as it was sent, with `decidedVia: "rule"`), `timed_out` (no decision, no default) or `withdrawn`. - Body fields (camelCase; unknown fields are rejected): `project` (slug, created on first use), `title`, `body` (Markdown), `channel`, `icon`, `url`, `options` (`[{id,label,style}]`, up to 6), `allowText` (free-text decisions), `timeoutSeconds` (10 s to 30 days, default 24 h), `defaultOption`, `callbackUrl` (HTTPS, gets a signed `ask.settled` POST), `idempotencyKey`, `metadata`. For a server, prefer a webhook endpoint (an Owner sets it up once per workspace: every ask, event and threshold alert, Standard Webhooks signing); see Webhooks under Docs. - Withdraw an ask you no longer need: `POST /v1/asks/{id}/withdraw`. - List: `GET /v1/asks?status=waiting&limit=50` (newest first; pass the response's `nextCursor` back as `cursor` for the next page). One ask: `GET /v1/asks/{id}`. - Don't proceed with a risky action until the ask is decided with the approving option by a person: a decision with `decidedVia: "timeout"` (the default applied) is never an approval. `defaultOption` can't be an approving option (id `approve` or style `primary`): 400 `approving_default`; use `decline`. Pre-approval is an Owner's rule. ## Events and metrics ```sh curl -s https://api.logu.dev/v1/events -H "Authorization: Bearer $LOGU_API_KEY" \ -H "Content-Type: application/json" -d '{"project":"shop","title":"Deploy finished","icon":"🚀"}' curl -s https://api.logu.dev/v1/metrics -H "Authorization: Bearer $LOGU_API_KEY" \ -H "Content-Type: application/json" -d '{"project":"shop","metric":"orders","value":42}' ``` ## Images (attachments) Show the human images with an ask or event, e.g. a UI change's before/after screenshots: upload each (PNG, JPEG, WebP or GIF, up to 5 MB, checked by its bytes), then list the ids (up to 4). ```sh curl -s "https://api.logu.dev/v1/attachments?name=after.png" -H "Authorization: Bearer $LOGU_API_KEY" \ -H "Content-Type: image/png" --data-binary @after.png # → {"id":"att_…","url":"https://api.logu.dev/a/att_…?exp=…&sig=…","expiresAt":"…",…} curl -s https://api.logu.dev/v1/asks -H "Authorization: Bearer $LOGU_API_KEY" -H "Content-Type: application/json" \ -d '{"project":"web","title":"Ship the new checkout?","attachments":["att_…","att_…"]}' ``` An upload belongs to one ask or event, by the agent that uploaded it; one nothing uses within 24 hours is deleted. Asks and events return `attachments: [{id,url,contentType,size,width,height,name}]`; `url` is a signed link that works for at least an hour. CLI: `--image file` (repeat); SDKs: `uploadAttachment` / `upload_attachment`; MCP: `images: [{data_base64, content_type}]` (or `path` through `logu mcp`) or `attachments: ["att_…"]`. ## MCP Streamable HTTP at `https://api.logu.dev/mcp` with the same bearer API key. Tools: `ask_human` (waits up to `wait_seconds`, max 50), `get_decision` (call again while it's waiting), `withdraw_ask`, `list_asks`, `send_event`, `report_metric`, `whoami`. Results carry `approved` and `next` (what to do now). ```sh claude mcp add --scope user logu -- npx -y @logu/cli mcp # stdio bridge, reads the saved API key claude mcp add --transport http logu https://api.logu.dev/mcp \ --header "Authorization: Bearer $LOGU_API_KEY" ``` MCP takes an API key (from the Agents page, or the one `logu login` saved), or OAuth: clients that support MCP authorization (claude.ai and ChatGPT connectors, Cursor) connect to `https://api.logu.dev/mcp` with a sign-in and a click, no key to paste. OAuth access tokens work only on `/mcp`. `GET /v1/me` (and `whoami`) includes `locale`, the human's language (`en`, `pt-BR`; null means English): write titles and bodies in it. ## SDKs and CLI - TypeScript: `@logu/sdk` — `new Logu().ask({ project, title })` waits for the decision; `logu.guard(fn, { project })` runs `fn` only once approved. - Python: `pylogu` — `Logu().ask("shop", "Deploy?")`, `@logu.requires_approval("shop")`. - CLI: `logu ask "Deploy?" && ./deploy.sh` (exit 0 approved, 1 declined, 2 no decision); `npx -y @logu/cli install claude-code` connects Claude Code in one command (and one click in the browser), approves its permission prompts from your phone, turns the question Claude ends a turn with into an ask whose answer continues the session, and registers `logu mcp` (Logu's MCP tools over stdio) for every session; `npx -y @logu/cli install codex` does the same for OpenAI Codex (hooks.json, `[mcp_servers.logu]` and notify in config.toml, a Logu section in AGENTS.md), each as its own agent (profiles `claude-code`, `codex`); `logu login`, `logu whoami`, `logu profiles`, `logu logout`. ## Retries and idempotency Send `Idempotency-Key: ` on `POST /v1/asks`, `/v1/events` and `/v1/metrics` (asks also take `idempotencyKey` in the body). A retry with the same key and body returns the first result (200, `Idempotent-Replayed: true`); the same key with another body is a 422 `idempotency_key_reused`. Keys are per agent and remembered 24 hours. ## Errors `{"error":{"code","message","hint","docUrl","requestId","issues"?,"ask"?}}`. Branch on `code`; `hint` says what to do next; quote `requestId` (also the `Logu-Request-Id` header) when reporting a problem. Codes: `bad_request`, `unknown_field` (with "did you mean"), `unauthorized`, `forbidden`, `not_found`, `method_not_allowed`, `conflict` (an ask that's no longer waiting; `ask` has its state), `idempotency_key_in_use`, `idempotency_key_reused`, `rate_limited`, `internal_error`, `service_unavailable`, `plan_limit` (402: the workspace's plan is out of asks, events, metric reports, image storage, projects or metric names, or on Free of agents; nothing was created, so don't proceed: tell your human) and `ceiling_reached` (402: one of Logu's always-on cost ceilings, same advice, upgrading doesn't lift it; don't retry before the 1st UTC); while connecting with a device code, `authorization_pending`, `slow_down`, `expired_token`, `access_denied`. Retry 429 and 5xx with backoff (honor `Retry-After`); don't retry other 4xx. ## Limits Per API key, per minute: 60 asks, 600 events and metrics, 120 images, 1200 requests in all, and 3000 requests per workspace (all its keys together) (429 `rate_limited` with `Retry-After` and `RateLimit-*` headers). Title 200 characters, body 10,000, 6 options, 4 images of 5 MB, ask expiry 10 s to 30 days, one long-poll 50 s. ## Docs - [Docs](https://logu.dev/docs/): quickstarts for curl, TypeScript, Python, the CLI, Claude Code and MCP, and connectors (claude.ai, ChatGPT, Cursor) - [Agent guide](https://logu.dev/docs/agents): Claude Code hooks, Codex, MCP, CLI, SDKs, agent frameworks - [Errors](https://logu.dev/docs/errors): every code, its meaning and what to do - [Webhooks](https://logu.dev/docs/webhooks): webhook endpoints, payloads, verification, retries - [Reaching the human](https://logu.dev/docs/delivery): push, browser notifications, email, Telegram, Slack, WhatsApp, quiet hours, routing - [Attachments](https://logu.dev/docs/attachments): images on asks and events - [Glossary](https://logu.dev/docs/glossary): the vocabulary - [API reference](https://logu.dev/docs/api): every endpoint, with examples, errors and `x-agent-hint`, generated from the [OpenAPI 3.1 document](https://api.logu.dev/openapi.json) - [Request and response schemas](https://github.com/kevinqz/logu/blob/main/packages/protocol/src/index.ts) --- # Agent guide (docs/agents.md) Three ways for an agent to reach its human, from least to most code: | | Use it for | Waits for the human | |---|---|---| | **Claude Code hooks** (`@logu/cli`) | Approve Claude Code's tool permissions from your phone; answer the question Claude ends a turn with, and the session goes on; get "done" and "needs input" pings | Yes, up to `LOGU_HOOK_WAIT` (permissions) and `LOGU_HOOK_STOP_WAIT` (questions) | | **Codex** (`@logu/cli`) | The same for OpenAI Codex: approvals on your phone, closing questions answered from anywhere, finished turns as events, and Logu's MCP tools | Yes, like Claude Code | | **MCP server** (`/mcp`) | Any MCP client (Claude Code, Claude, agents you build) gets `ask_human`, `get_decision`, `withdraw_ask`, `list_asks`, `send_event`, `report_metric`, `whoami` | Yes, in steps of up to 50 s | | **SDKs** (`@logu/sdk`, `pylogu`) and the **CLI** | Scripts, CI jobs, your own agents | Yes (`ask()` / `logu ask`) | All of them authenticate with an API key. Everything the agent sends is attributed to it, and goes to the agent's workspace: its Owners and Deciders get its asks (per project routing), and `decision.decidedBy` says who decided. An API key never sees another workspace's asks. ## Connect with one command On a machine with you at it, nothing to copy: ```sh npx -y @logu/cli install claude-code # Claude Code: connects, then adds the hooks npx -y @logu/cli install codex # OpenAI Codex: the same (see Codex below) npx -y @logu/cli login # any other script on this machine ``` 1. The terminal shows a code (`WDJB-MJHT`) and opens `https://logu.dev/connect?code=WDJB-MJHT` (sign in first if you need to; you come back to it). 2. Check that the code matches, pick the workspace and the agent: **New agent** (named after the client, "Claude Code on kevins-mac", which you can change) or one the workspace already has, like "Codex" (the page suggests the one matching the client). Click **Connect**. Only Owners and Deciders can connect agents. 3. The terminal says "Connected as Claude Code on kevins-mac in Ana". A new API key for that agent is saved in `~/.config/logu/config.json` (`$XDG_CONFIG_HOME/logu`, or `%APPDATA%\logu` on Windows; mode 0600), under the client's **profile**, and never printed. An existing agent keeps its other API keys; the new one is named after the client ("Codex on kevins-mac"). The config file holds one profile per agent: `install claude-code` uses the `claude-code` profile, `install codex` the `codex` one, `logu login` the `default` one (`--profile ` for another). So Claude Code and Codex on the same computer are two agents, each with its own asks, events and activity. The CLI, the hooks, `@logu/sdk` (in Node) and `pylogu` (the `LOGU_PROFILE` profile, else the file's `current`, else `default`) read the API key from there when `LOGU_API_KEY` isn't set; `LOGU_API_KEY` always wins. `logu whoami` shows the agent, workspace, profile and where the key came from; `logu profiles` lists the profiles (never the keys); `logu logout --profile ` removes one (the agent stays in the app, where you can revoke its API keys or delete it). The activity log records `agent.created` (a new agent only), `agent.connected` and `api_key.created` (`via: connect`). A config file from before profiles (`{ "apiKey": … }`) is still read, as both `default` and `claude-code` (the only integration then), and is saved in the new format on the next write. How it works (OAuth 2.0 Device Authorization Grant, RFC 8628 style; for your own clients too): `POST /v1/device/code` (no credentials, rate-limited per IP) returns a secret `device_code`, the `user_code` and `verification_uri_complete` (send `client_name` and `client_kind`: `claude-code`, `codex` or `cli`, so the page suggests the right agent); the human approves with `POST /v1/device/approve` as a new agent (`agentName`) or an existing one (`agentId`); the client polls `POST /v1/device/token` every `interval` (5) seconds, getting `authorization_pending` (or `slow_down`: add 5 seconds) until the human approves, then the API key exactly once; `access_denied` if they cancel, `expired_token` after 10 minutes or once the key was delivered. Logu stores only hashes of both codes; the API key is created when it's delivered and only its hash is kept, like any API key. **Python** (`pylogu`) reads the same file in the same order as `@logu/sdk`: `api_key=`, else `LOGU_API_KEY`, else the saved profile (and its base URL, unless `base_url=` or `LOGU_BASE_URL` is set). `pylogu.config.read_config()` reads it yourself. ### Advanced: use an API key (CI, servers) Where nobody can click Connect, create an agent in the app (Agents page): it comes with its first API key, shown once. Then: ```sh export LOGU_API_KEY=lg_... # The API is https://api.logu.dev; when self-hosting, also: # export LOGU_BASE_URL=https:// ``` An agent can check who it is, its workspace, whether its API key is in test mode and its limits with `GET /v1/me` (or the MCP tool `whoami`). For an LLM, the short guide is at https://api.logu.dev/llms.txt (built from [llms.txt](llms.txt)); https://api.logu.dev/llms-full.txt adds this page and the error codes. ### Quieter events: the notification level Every agent has a **notification level**: `asks_and_events` (the default), `asks_only` or `silent`. Below `asks_and_events` its events are stored and shown in the app but never delivered (no push, browser notification, Telegram, Slack or WhatsApp); its **asks are always delivered**. Agents connected with `logu install claude-code` or `logu install codex` start at `asks_only`, since a coding agent's "turn finished" events would otherwise fill the lock screen; every other agent starts at `asks_and_events`. Owners and Deciders change it on the Agents page, from an event or an ask ("Mute this agent's events"), or with `PATCH /v1/agents/{id}` and `{"notificationLevel": "silent"}` (agents can't change it themselves). See [delivery.md](delivery.md#notification-level-per-agent). ### Test mode: build without buzzing anyone A **test API key** (`lg_test_…`) works like any other, against the same API, with the same limits, but nothing it sends reaches anyone's phone: its asks, events and metrics are marked `test: true`, show up in the app with a **Test** badge (the inbox's "Hide test" leaves them out) and are never delivered (no push, browser notifications, email, Telegram or Slack, no reminders). So you can wire up an agent, a CI job or a webhook handler without every Owner and Decider of the workspace getting notified. - **Get one:** Agents → an agent's menu → **Add a test API key**, `logu login --test` (saves it to the profile like any login: pair with `--profile test`), or `POST /v1/agents/{id}/api-keys` with `{"test": true}` (`POST /v1/agents` takes `test` too). - **Check which one you have:** the prefix says it, and `GET /v1/me` (MCP `whoami`, `logu whoami`) returns `apiKey.test`. - **Asks** still need a human: decide them in the app (open the inbox; nobody is notified), or let them time out and the default apply. Agents never decide, in test mode either. The signed `ask.settled` callback is sent as usual, so you can test your webhook handler end to end. - **Metrics** reported with a test API key are a metric of their own (a test `orders` never moves the live `orders`), listed under "Test metrics" in Insights and kept out of the long-term history and the ask stats. Their threshold alerts create test events. - **Activity:** what a test API key does is in the activity log like anything else, with `test: true`. - **Going live:** swap `LOGU_API_KEY` for a live API key (`lg_…`); nothing else in your code changes. Revoke the test API key when you're done, or keep it for CI. ## Claude Code ```sh npx -y @logu/cli install claude-code # connects (see above), adds the hooks to ~/.claude/settings.json # and registers Logu's MCP server (`logu mcp`) logu install claude-code --scope project # or .claude/settings.json (local: .claude/settings.local.json) logu install claude-code --wait 120 # permission requests wait 2 minutes for you (default 300) logu install claude-code --stop-wait 300 # closing questions wait 5 minutes (default 600; 0: events) logu uninstall claude-code # removes them again, keeping your other settings ``` Claude Code is its own agent: the hooks and the MCP server run as the `claude-code` profile (`logu --profile claude-code hook claude-code stop …`). Without an API key for it, install connects first as "Claude Code on "; pick your existing "Claude Code" agent or a new one. The hooks run `logu` when it's installed (`npm install -g @logu/cli`), else `npx -y @logu/cli` (`--command` to choose). They never hold the API key: each run reads it from `LOGU_API_KEY` or the profile. Installs from before profiles (`logu hook claude-code …`) keep working with the default API key (which, from a pre-profiles config file, is also `claude-code`'s); running `install claude-code` again replaces them with the profile form. `install` also gives every Claude Code session Logu's MCP tools, so Claude can ask you and tell you things on its own, not only when a hook fires: it runs `claude mcp add --scope user logu -- logu --profile claude-code mcp` (same scope as the hooks; `npx -y @logu/cli --profile claude-code mcp` without a global install), or prints that command when the `claude` CLI isn't on your PATH. `logu mcp` is a stdio bridge to `https://api.logu.dev/mcp` that reads the API key like the hooks, so Claude Code's config holds only the command. `uninstall` runs `claude mcp remove`. What the hooks do: - **PermissionRequest** — when Claude Code would show a permission prompt, Logu asks on your phone ("Claude Code: Run: pnpm wrangler deploy", "Claude Code: Executar: …" in Portuguese). **Approve** → the tool runs; **Decline** → Claude is told you denied it, with anything you typed ("use pnpm instead") as the reason. If nobody decides within the wait (`install --wait`, else `LOGU_HOOK_WAIT`, default 300 s), the ask is withdrawn and Claude Code shows its normal terminal prompt. Logu failures never block Claude Code: requests time out after 8 s, and the hook exits with a non-blocking error so the normal prompt appears. A tool Logu has no words for shows its input as short `key: value` lines (long values cut, nested fields one level deep), never raw JSON. - **AskUserQuestion** (through the same PermissionRequest hook) — when Claude asks you a multiple-choice question, Logu doesn't ask for permission to ask: it asks **the question**. Each question is its own ask, all sent at once in order (one combined ask couldn't give each question its own buttons): the title is the question, the body its header and every option numbered with its description, the options are the buttons (up to 6; any further ones answer by number), and a text reply is Claude Code's "Other". A multi-select question takes several options as their numbers in a text reply ("1, 3", "2 e 4"), or one button for just one. When every question is answered within the wait, the tool completes with your answers (`decision.updatedInput` with the `questions` and `answers` keyed by question text, multi-select labels joined with ", ", as Claude Code documents); otherwise the asks left are withdrawn and Claude Code shows its own dialog. The asks are the session's, so answering in the terminal withdraws any still open at your next prompt. - **Notification** — Claude Code's "waiting for your input" and similar notices reach your phone. Its own "needs your permission" notice is skipped, since the PermissionRequest hook already asks (set `LOGU_NOTIFY_PERMISSION_PROMPTS=1` to get both). - **Stop** — what reaches your phone depends on how Claude's last message ends: - **It ends with a question for you** → an **ask**, and your answer continues the session. The title is the question, the body the message before it (Markdown, about 1,500 characters). Yes/no questions ("Faço o merge do #52 quando os builds passarem?", "Should I open the PR?", "A or B?") get **Sim / Não** (Yes / No, in your language; ids `approve` / `decline`) plus a text reply; open questions ("Which name do you prefer?") get a text reply and a single **Você decide** / **Your call** (`continue`). The hook waits for you (`install --stop-wait`, else `LOGU_HOOK_STOP_WAIT`, default 600 s; Claude Code's hook timeout is that plus 30 s), then tells Claude your answer as its next instruction (`decision: "block"`), e.g. "Kevin respondeu pelo Logu: Sim — pode fazer o merge.", and Claude goes on by itself. - **Anything else** → an **event** titled with the first heading or sentence of the message ("Deployed logu-api to production."), the message as its body; "Claude Code finished" when there's no message. - **Only JSON** (structured output, `claude -p --json-schema`) → nothing: it's not for you, and a JSON line is never a title. What counts as a closing question is deliberately narrow: the last paragraph's last sentence ends with "?". A "?" mid-text, inside code blocks, inline code or URLs, or a message ending in a code block, is not one. The ask expires in an hour, with no default. If you don't answer within the wait, Claude stops as usual and the ask **stays open** (until it expires): answering it later reaches nobody, since that session has already stopped. Answer in the terminal instead and it's withdrawn: the **UserPromptSubmit** hook (`logu hook claude-code user-prompt`, quick and silent, never blocks a prompt) withdraws the session's open questions when you send your next prompt. While the hook waits, Claude Code shows it running; press Esc to answer in the terminal right away. Claude never gets the same question twice in a session (the hook remembers question hashes per session in `~/.config/logu/stop-asks.json`), so a session that continues because of your answer can ask a new question but can't loop on the same one. A retried hook doesn't duplicate the ask (its idempotency key is the session plus a hash of the message). `--stop-wait 0` turns closing questions back into events. - **UserPromptSubmit** — you typed in the terminal, so whatever this session asked on your phone is answered: the hook withdraws the session's asks that are **still waiting** (it reads each one first; one you already decided on the phone is left as it is). That covers the Stop hook's closing questions and the asks Claude made itself in the session: `logu ask …` run through Bash (Claude often stops waiting for it, at its Bash timeout or when you interrupt), and `ask_human` through `logu mcp` when the call returned with the ask still waiting. Each records its open ask in the same per-session file (`~/.config/logu/stop-asks.json`) and drops it once it settles (a decision, a timeout, a withdrawal, or `get_decision` / `withdraw_ask` showing it settled). The hook withdraws them with its own profile's API key (`claude-code`, or `codex`), so this works when the ask was made in the same workspace (an API key reads and withdraws only its workspace's asks); one from another workspace just leaves the list and expires. - **SessionStart** — tells the session's Bash commands which session they run in, so `logu ask` knows (`logu hook claude-code session-start`; local and instant, no API key or network). How a command finds its session: `LOGU_CLAUDE_SESSION_ID`, which the SessionStart hook appends to the file in Claude Code's `CLAUDE_ENV_FILE` (Claude Code's documented way for a hook to set variables for the session's Bash commands; it runs again on `/clear` and `--resume`, so it follows the session), else `CLAUDE_CODE_SESSION_ID`, which Claude Code sets in the environment of the commands and MCP servers it starts. Both are the `session_id` hooks get. Outside Claude Code neither is set and `logu ask` records nothing. `logu mcp` is started once per session, so it uses `CLAUDE_CODE_SESSION_ID`; after `/clear` its asks belong to the previous session id and the next prompt doesn't withdraw them (they still expire). Logu down or no API key: every hook steps aside (a non-blocking hook error) and Claude Code behaves as if Logu weren't there. The project is the git repository's name (the closest directory up with `.git`, else the session's directory name; override with `LOGU_PROJECT`), with the session's directory inside it (`apps/api`) as `metadata.cwd`; the channel is `claude-code`. Logu's own words (the fallback title, notification titles, "Run:", Sim / Não) follow your language in the app (`GET /v1/me`'s `locale`, cached for a day). `logu install claude-code --print` shows the settings without writing them, and the `claude mcp add` command on stderr. With an API key in `LOGU_API_KEY` instead of the config file, Claude Code only sees it (and `LOGU_BASE_URL`, if set) in the environment it was started in: export them in your shell profile. ## Codex ```sh npx -y @logu/cli install codex # connects (see above), then wires Logu into Codex logu install codex --wait 120 --stop-wait 300 # the same waits as Claude Code's logu install codex --print # show the files it would write, write nothing logu uninstall codex # takes it all out again; notify back exactly as it was ``` [OpenAI Codex](https://github.com/openai/codex) has [hooks](https://learn.chatgpt.com/docs/hooks) in the same shape as Claude Code's, so Logu's Codex integration is the Claude Code one: everything above about the PermissionRequest, Stop and UserPromptSubmit hooks holds, with "Codex" in the titles and the channel `codex`. `install codex` changes three files in `CODEX_HOME` (default `~/.codex`), keeping a copy of each one the first time (`.bak-logu`) and everything else in them as it was. Codex is its own agent, not Claude Code's: everything below runs as the `codex` profile (`logu --profile codex …`), and without an API key for it, install connects first as "Codex on "; on the /connect page pick your "Codex" agent (it's suggested) or a new one, and it gets a new API key named "Codex on ". To switch an earlier install (which used the default API key, so Codex showed up as Claude Code), run `logu install codex` again: it connects the `codex` profile and rewrites the commands. `--profile` picks another profile. - **`hooks.json`** — Logu's PermissionRequest (`logu --profile codex hook codex permission-request --wait 300`), Stop (`--wait 600`) and UserPromptSubmit hooks, next to yours. Codex runs a new or changed hook only once you trust it: open Codex and approve them in `/hooks`. Hooks are on by default; `[features] hooks = false` turns them off (install says so). - **`config.toml`** — `[mcp_servers.logu]` (`command = "logu"`, `args = ["--profile", "codex", "mcp"]`, or the same through `npx -y @logu/cli`; `tool_timeout_sec = 120`, above `ask_human`'s 50 s wait and Codex's 60 s default; `startup_timeout_sec = 30` for npx), replacing an earlier `[mcp_servers.logu]`. And **`notify`**, Codex's one turn-complete program, wrapped: `notify = ["logu", "--profile", "codex", "hook", "codex", "notify", "--", …]`. The wrapper runs that program first with the same JSON (never waiting for it, never failing it: Codex Computer Use's `turn-ended` keeps working), then sends the turn to Logu: an event titled with Codex's first sentence, or, when it ends with a question, an ask. Only real turns: Codex's notify JSON is `{"type": "agent-turn-complete", "thread-id", "turn-id", "cwd", "client", "input-messages", "last-assistant-message"}`, and any other `type` is skipped, as is a turn whose last message is JSON (Codex's internal turns, such as its suggestions or memory passes, end with `{"suggestions": […]}` or `{"exclude": []}`). A turn with no project directory goes to the `codex` project. A comment above it keeps the original line, which `uninstall` puts back byte for byte. `--no-notify` leaves notify alone. - **`AGENTS.md`** (`AGENTS.override.md` when that's the one Codex reads) — a short "Logu" section between `` and ``: use `ask_human` / `logu ask` for decisions only you can make while you're away, `send_event` / `logu event` for milestones, in your language, never with secrets. Approvals: Codex's `approvals_reviewer` takes only `user` or `auto_review` (its own reviewer agent), not an external program; the PermissionRequest hook is how approvals reach your phone. Approve → the command runs; Decline → it's denied, with your text as the reason; no decision, Logu down or no API key → Codex shows its normal prompt. A Logu failure never approves anything. Stop and notify both see every finished turn, so they don't double up: the Stop hook remembers the turns it sent (`~/.config/logu/codex-turns.json`) and notify skips them. Until you trust the hooks (or with hooks off), notify is what reaches you, with two limits: it can't wait or answer Codex (it runs after the turn, with Codex not listening), so a closing question is an ask whose answer continues nothing (answer in the terminal); and approvals stay in the terminal. Asks Codex makes itself are withdrawn too when you answer in the terminal: Codex sets `CODEX_THREAD_ID` (its thread id, the `session_id` its hooks get) for the commands it runs, so a `logu ask` from Codex's shell records its open ask for the thread, and Codex's UserPromptSubmit hook withdraws it if it's still waiting. Codex has no `CLAUDE_ENV_FILE` equivalent and doesn't need one, so `install codex` adds no SessionStart hook. `ask_human` through `logu mcp` is recorded only when Codex gives the MCP server `CODEX_THREAD_ID`; otherwise those asks just expire. ## MCP With the CLI (after `logu login`, or `logu install claude-code`, which does this for you): ```sh claude mcp add --scope user logu -- logu --profile claude-code mcp # stdio; the API key stays in Logu's config ``` Or over HTTP, for clients that take a URL and a header: ```sh claude mcp add --transport http logu https://api.logu.dev/mcp \ --header "Authorization: Bearer $LOGU_API_KEY" ``` With an API key from the Agents page (or `jq -r .profiles.default.apiKey ~/.config/logu/config.json` after `logu login`), or with no key at all through OAuth, below. ### Connect from claude.ai / ChatGPT / Cursor Clients that support MCP authorization connect with a sign-in and one click; nothing to paste. Give them the MCP URL: ```text https://api.logu.dev/mcp ``` - **claude.ai and Claude Desktop**: Settings → Connectors → Add custom connector → the URL above. - **ChatGPT**: Settings → Connectors (developer mode) → Create → the URL above, authentication OAuth. - **Cursor, VS Code, Claude Code** and other clients that take a URL: `claude mcp add --transport http logu https://api.logu.dev/mcp` (no header), or `{"mcpServers": {"logu": {"url": "https://api.logu.dev/mcp"}}}`. The client opens the browser when it first connects. The client opens Logu's **Connect an agent** page: sign in, check the app's name and where it goes back to (claude.ai, chatgpt.com, or "this computer" for a local app), pick the workspace and a new agent (named after the app) or an existing one, and click **Connect**. The app is then a **connector** of that agent: it shows among the agent's API keys on the Agents page (each agent folds its keys behind a line like "2 API keys · 1 connector", and shows whether it is Active, with a key used in the last 24 hours, or Idle), where **Disconnect** ends it (so does deleting the agent). How it works (MCP authorization, 2025-11-25): `/mcp` answers 401 with `WWW-Authenticate: Bearer resource_metadata="https://api.logu.dev/.well-known/oauth-protected-resource/mcp"`; the client reads the Protected Resource Metadata (RFC 9728) and the Authorization Server Metadata (`/.well-known/oauth-authorization-server`, RFC 8414), registers itself (`POST /oauth/register`, RFC 7591; redirect URIs must be https or a loopback address), and runs the authorization code flow with PKCE S256 and `resource=https://api.logu.dev/mcp` (RFC 8707). Access tokens (`lga_…`) last an hour and work **only on `/mcp`** (use an API key for the REST API); refresh tokens (`lgr_…`) last 90 days and rotate on every use, and using a rotated one again disconnects the connector. `POST /oauth/revoke` (RFC 7009) disconnects it too. Webhooks (`callback_url`) aren't for connectors: they're signed with the API key, which a connector doesn't have; use `get_decision`. | Tool | What it does | Annotations | |---|---|---| | `ask_human` | Ask and wait up to `wait_seconds` (default 45, max 50) | writes, reaches a human | | `get_decision` | Read an ask, waiting up to `wait_seconds` again | read-only, idempotent | | `withdraw_ask` | Take back a waiting ask | destructive, idempotent | | `list_asks` | Asks, newest first (waiting by default; `all`, `status`, `limit`, `cursor`: pass the previous result's `nextCursor`) | read-only | | `send_event` | Tell the human something happened | writes, reaches a human | | `report_metric` | Report a number for Insights | writes | | `whoami` | The agent, its workspace and limits | read-only | `ask_human` takes `project`, `title` and optionally `body`, `options`, `allow_text`, `expires_in_seconds`, `default_option`, `channel`, `icon`, `url`, `callback_url`, `metadata`, `idempotency_key` and `wait_seconds` (`question`, `details` and `timeout_seconds` still work as aliases of `title`, `body` and `expires_in_seconds`). If the human hasn't decided within `wait_seconds`, it returns the waiting ask: call `get_decision` with its `ask_id`, as often as needed. The cap of 50 seconds keeps each call under typical MCP client timeouts; it never changes the ask's expiry. Results are compact (no nulls): a first line that says what happened, when a waiting ask times out and what to do next, then JSON with `approved` (true only when a person decided with Approve; a default applied on timeout is never an approval) and `next`. Don't let an agent proceed until `approved` is true. Pass `idempotency_key` when a client might retry a call, so a retry never creates a second ask or event. API errors come back as tool errors with the code, message and hint. ## Pre-approval is a rule, not a default An ask's `defaultOption` (`default_option`, `--default`) is what Logu records when time runs out and nobody answered. It can **fail closed** (`decline`), but an agent cannot use it to say yes for the human: a default that is an approving option (the id `approve`, or any option with `style: "primary"`) is refused with 400 `approving_default` and nothing is asked ([errors.md](errors.md#approving_default)). To approve certain asks in advance, an Owner creates a **rule** in the app (Settings → Rules); a rule's decision is `decidedVia: "rule"` and recorded as theirs. Every client also treats a decision with `decidedVia: "timeout"` as *not* approved (SDK `approved()`, `pylogu.approved()`, the CLI's exit code, the n8n node, the hooks, MCP's `approved`), and so should your own code: check `decidedVia`, not only `optionId`. ## REST ```sh curl -s https://api.logu.dev/v1/asks -H "Authorization: Bearer $LOGU_API_KEY" \ -H "Content-Type: application/json" -H "Idempotency-Key: deploy-4821" \ -d '{"project":"shop","title":"Deploy v1.4?","timeoutSeconds":3600,"defaultOption":"decline"}' # then, while "status" is "waiting": curl -s "https://api.logu.dev/v1/asks//wait?timeout=50" -H "Authorization: Bearer $LOGU_API_KEY" ``` - **Strict bodies.** Field names are camelCase and unknown fields are rejected (400 `unknown_field`, with "did you mean" in the message and `hint`), so a `timeout_seconds` typo can't silently become a 24-hour ask with no default. - **Idempotency.** Send `Idempotency-Key` on `POST /v1/asks`, `/v1/events` and `/v1/metrics` (asks also take `idempotencyKey` in the body). Retrying with the same key and body returns the first result with status 200 and `Idempotent-Replayed: true`; another body with the same key is a 422 `idempotency_key_reused`. Keys are per agent and remembered 24 hours. - **Errors** are `{ error: { code, message, hint, docUrl, requestId, issues?, ask? } }`; every code is in [errors.md](errors.md). A 409 about an ask includes the ask as it is now. - **Pagination.** `GET /v1/asks`, `/v1/events` and `/v1/activity` return newest first, at most `limit` items, and `nextCursor`: send it back as `?cursor=` for the next page; it is `null` at the end of the list. Cursors are opaque and belong to their list (another list's, or a made-up one, is a 400 `bad_request`). Items created in the same millisecond keep one order, so pages never skip or repeat them. `before` (the last id of the previous page) still works on asks and the activity log. The SDKs page for you: `listAllAsks()` / `list_all_asks()`. - **Rate limits** per API key, per minute: 60 asks, 600 events and metrics, 1200 requests in all (and 3,000 requests per workspace, all keys together). Over a limit: 429 `rate_limited` with `Retry-After` and `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `RateLimit-Policy`. The SDKs wait and retry. - **Plans and limits.** Free has monthly quotas (100 asks, 1,000 events, 10,000 metric reports), 5 projects, 20 metric names, 200 MB of images and 2 agents; Pro has fair-use quotas (5,000 asks, 50,000 events, 250,000 metric reports, 20 GB, 100 projects, 500 metric names). Past a quota, when the server enforces them: 402 `plan_limit`. Past one of Logu's always-on cost ceilings (20,000 asks, 200,000 events, 1,000,000 metric reports a month, the same lower for test API keys, 25 GB of images, 500 projects, 2,000 metric names, 20 API keys per agent): 402 `ceiling_reached`. Both are `PlanLimitError` in the SDKs and a `plan_limit:` / `ceiling_reached:` tool error over MCP, with a hint. **Nothing was created: an ask over a limit was not asked, so never proceed as if it were approved; tell your human and don't retry before the 1st (UTC).** The same applies to an image upload that is refused (send the ask without it) and to a new project or metric name (reuse an existing one). A workspace's API keys also share 3,000 requests a minute (429 `rate_limited`; more keys don't raise it). - **WhatsApp and email are capped, never blocking.** If the workspace's WhatsApp or email cap is reached, your ask is still created and shows in the app and on every other delivery; only that channel skips the send. You see nothing different in the response. On WhatsApp only an ask and its reminder can reach a person outside the 24-hour window; the outcome and events go out there only inside the window (nothing is lost: they are in the app). - **Test API keys have quotas too, on Free.** What a test key sends is never delivered, but when the server enforces plan limits a Free workspace gets 100 test asks, 1,000 test events and 10,000 test metric reports a month, 300 emails and 400 image uploads (`plan_limit`; Pro has the cost ceilings only). The same rule as above: a refused ask was not asked. - **Agents never create workspaces or accounts.** `POST /v1/workspaces` and the sign-in routes are for humans; an account owns at most 3 shared workspaces (25 with a Pro one), so one person cannot multiply a workspace's limits. Connecting an agent (device code, OAuth) happens behind a human's session and needs no captcha. - **Webhooks.** For a server that should hear about every ask, event and threshold alert, an Owner adds a webhook endpoint (Settings → Webhooks, `/v1/webhooks`): Standard Webhooks signing with a signing secret of its own, retries, a delivery log and test sends. An ask's `callbackUrl` (`callback_url` in MCP and Python) still gets one signed `ask.settled` POST, but its signature depends on the agent's API key. See [webhooks.md](webhooks.md). ## Insights Report a number whenever it changes or on a schedule (`POST /v1/metrics`, `logu metric`, MCP `report_metric`, SDK `metric()`): its card in Insights shows the latest value, a sparkline of the last points and its threshold alerts. Clicking the card opens the metric's history over 24h, 7d, 30d, 90d or 1y with min, max, average and change, next to the previous period's average. Keep `project` and `metric` stable (they are the history's key) and send the unit you want shown (`$`, `%`, `ms`, `orders`). The history is read by members, not agents: `GET /v1/metrics/{id}/history?range=30d` (session auth) returns buckets (average, min, max, count), a summary and the previous period's summary (`previous`, the same length just before). It comes from Analytics Engine when the server has it set up, else from the last 200 reports (`source: "recent"`); test metrics are always only their recent reports. ## CLI ```sh logu event "Nightly backup finished" --icon ✅ logu metric revenue 1620 --unit $ --label "Revenue today" # a metric card in Insights logu ask "Deploy to production?" --body "3 commits since v1.4" --expires-in 3600 && ./deploy.sh logu ask "Which region?" --option us=US --option eu=Europe # prints the chosen option id logu ask "Ship the new checkout?" --image before.png --image after.png # screenshots in the ask ``` `logu ask` exits 0 when a person approved (or decided, with custom options), 1 when declined and 2 when the ask ended without a decision (timed out, withdrawn, or `--wait` ran out). A default option applied on timeout is never an approval: a decline default exits 1, anything else 2. Errors have their own codes: 64 usage, 65 input rejected, 69 Logu unreachable, 77 API key (see `logu ask --help`). For agents driving the CLI: `logu help --json` describes every command, flag (type, default, values), exit code and environment variable, and the API operations each command calls; `--dry-run` on `event`, `metric` and `ask` checks the request like Logu would (exit 65 with each problem) and prints it (method, URL, body; never the API key) without sending anything. `logu completion bash|zsh|fish` prints a shell completion script. ## Python ```python from pylogu import Logu, approved logu = Logu() if approved(logu.ask("my-app", "Run the migration in production?", default_option="decline")): migrate() ``` See [packages/python/README.md](../packages/python/README.md). `pylogu` 1.0 lives in this repo and replaces the 2023 `kevinqz/pylogu` 0.x client. ## Guard any function Wrap a risky function so every call asks you first. It only runs once you approve; a decline, an expired ask (no decision before `timeoutSeconds`) or a withdrawn one raises instead. If the call stops waiting first (`wait` ran out, an error, Ctrl-C), the ask is withdrawn. ```ts import { Logu, NotApprovedError } from "@logu/sdk"; const logu = new Logu(); const dropTable = logu.guard((table: string) => db.dropTable(table), { project: "billing", title: (table) => `Drop table ${table}?`, timeoutSeconds: 30 * 60, }); await dropTable("invoices_old"); // waits for your tap; throws NotApprovedError otherwise ``` ```python from pylogu import Logu, NotApproved logu = Logu() @logu.requires_approval("billing", title=lambda table: f"Drop table {table}?") def drop_table(table: str) -> None: ... drop_table("invoices_old") # raises NotApproved unless you approve ``` `AsyncLogu.requires_approval` does the same for `async def` functions. The call's arguments become the ask's body (as JSON) unless you pass `body`. ## Agent frameworks Every framework that speaks MCP gets `ask_human`, `get_decision`, `withdraw_ask`, `list_asks`, `send_event`, `report_metric` and `whoami` from `https://api.logu.dev/mcp` with your API key as a bearer token (or OAuth, for clients that support it: see above). **Claude Agent SDK** (TypeScript): ```ts import { query } from "@anthropic-ai/claude-agent-sdk"; for await (const message of query({ prompt: "Clean up the stale feature flags, asking me before deleting any.", options: { mcpServers: { logu: { type: "http", url: "https://api.logu.dev/mcp", headers: { Authorization: `Bearer ${process.env.LOGU_API_KEY}` }, }, }, }, })) { if (message.type === "result") console.log(message.result); } ``` **OpenAI Agents SDK** (Python): ```python import os from agents import Agent, Runner from agents.mcp import MCPServerStreamableHttp async with MCPServerStreamableHttp( name="logu", params={ "url": "https://api.logu.dev/mcp", "headers": {"Authorization": f"Bearer {os.environ['LOGU_API_KEY']}"}, "timeout": 60, }, client_session_timeout_seconds=60, # ask_human waits up to 45s for your decision ) as logu: agent = Agent(name="Ops", instructions="Ask the human before risky changes.", mcp_servers=[logu]) result = await Runner.run(agent, "Rotate the staging database password.") ``` **LangGraph / LangChain** (`langchain-mcp-adapters`): ```python import os from langchain_mcp_adapters.client import MultiServerMCPClient client = MultiServerMCPClient({ "logu": { "transport": "http", "url": "https://api.logu.dev/mcp", "headers": {"Authorization": f"Bearer {os.environ['LOGU_API_KEY']}"}, } }) tools = await client.get_tools() # pass to your LangGraph agent ``` **n8n**: install the community node **`n8n-nodes-logu`** (Settings → Community Nodes). Its **Logu** node sends events and metrics and creates, lists and withdraws asks; **Ask and Wait** pauses the workflow until the human decides and continues on **Approved**, **Declined** or **Timed Out** (n8n must be reachable at a public `https://` URL, since Logu calls the execution back). The **Logu Trigger** node starts a workflow from a webhook endpoint (Settings → Webhooks). See [its README](https://github.com/kevinqz/logu/tree/main/packages/n8n-nodes-logu#readme). For an **AI Agent** node, add an **MCP Client Tool** with the endpoint `https://api.logu.dev/mcp` (HTTP Streamable) and a header `Authorization: Bearer lg_…`: its `ask_human` tool waits for the decision inside the agent's run. `ask_human` waits up to `wait_seconds` (45 by default, 50 at most) per call. Long decisions return a waiting ask, and the agent resumes with `get_decision`. Tell your agent so in its instructions if your framework times tool calls out sooner. --- # Errors Every error from the Logu API (`https://api.logu.dev`) has the same shape: ```json { "error": { "code": "unknown_field", "message": "Unknown field \"timeout_seconds\" (did you mean \"timeoutSeconds\"?)", "hint": "Rename timeout_seconds to timeoutSeconds. Field names are camelCase.", "docUrl": "https://logu.dev/docs/errors#unknown_field", "requestId": "8c1f0b2a9d3e4f51-GRU", "issues": [{ "path": "timeout_seconds", "message": "Unknown field; did you mean \"timeoutSeconds\"?" }] } } ``` - `code` is stable: branch on it, not on `message`. - `message` is for people and includes the field problems. - `hint` says what to do next. - `docUrl` links to the code's section below. - `requestId` is also sent as the `Logu-Request-Id` header on every response (it's Cloudflare's `cf-ray` when there is one). Quote it when reporting a problem. - `issues` lists each invalid field (`path` is dotted: `options.0.label`). - `ask` comes with a `409 conflict` about an ask: its current state. The SDKs raise `ValidationError` (400), `PlanLimitError` (402, for `plan_limit` and `ceiling_reached`), `AuthenticationError` (401), `PermissionError` / `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429) and `LoguError` for the rest, each with `code` and `issues`. The codes are defined in `ERROR_CODES` in `packages/protocol/src/index.ts`; a test checks this page lists every one. ## `bad_request` HTTP 400. The request is malformed (the body isn't JSON) or a field is invalid: too long, the wrong type, a `defaultOption` that isn't one of the options, a decision option the ask doesn't have. Also creating an 11th webhook endpoint (a workspace has at most 10), or a webhook endpoint URL that isn't a public `https:` address. Hint: fix the fields listed in `issues` and send the request again. ## `approving_default` HTTP 400. The ask's `defaultOption` is an *approving option*: the one with the id `approve`, or any option with `style: "primary"` (the go-ahead; the default Approve / Decline pair's Approve is both). The default is what Logu records when nobody answers before the ask expires, so an agent can't make the go-ahead its own default: nobody would have said yes. Nothing was asked, so don't proceed. Allowed defaults: any other option (`decline`, a `danger` or `default` styled one) or none (the ask then ends `timed_out`). Asks stored before this rule keep the default they have. Pre-approval is a **rule**: an Owner decides in advance which asks to approve (Settings → Rules in the app), and the decision is recorded with `decidedVia: "rule"`. A decision with `decidedVia: "timeout"` is never an approval, whatever its `optionId`: check `decidedVia` (the SDKs, the CLI, the n8n node, the hooks and the MCP `approved` field already do). Hint: use `defaultOption: "decline"` (fail closed) or leave it out, or ask an Owner to create a rule for pre-approval. ## `unknown_field` HTTP 400. The body has a field the endpoint doesn't know. Request bodies are strict, so a typo or a snake_case name (`timeout_seconds`, `default_option`) is an error instead of being silently ignored (an ask meant to fail closed would otherwise never decide). Each unknown field is in `issues`, with a "did you mean" suggestion when there is a close match. Hint: remove or rename the fields listed in `issues`. Field names are camelCase (`timeoutSeconds`, `defaultOption`, `allowText`, `callbackUrl`, `idempotencyKey`). Query strings are not strict. ## `plan_limit` HTTP 402. This is over one of the workspace's **plan quotas** (docs/billing.md): its asks, events or metric reports this calendar month (UTC), the image storage it holds, its projects or metric names (on Free and on Pro, where the quotas are fair use), on Free also what test API keys send, the emails Logu sends for it and its image uploads, and on Free its agents, inviting members and webhook endpoints. Nothing was created: an ask over the limit was **not** asked, so an agent must not proceed as if a human had answered. These are only returned on servers that enforce plan limits (`BILLING_ENFORCED`); the always-on cost ceilings answer `ceiling_reached` instead. One more `plan_limit` is always on, whatever `BILLING_ENFORCED` says: a **Free account** (one that owns no workspace on Pro) that already owns 3 shared workspaces and creates a 4th, or becomes the Owner of one (`POST /v1/workspaces`, promoting a member, accepting an Owner invitation). Its hint says to upgrade one of its workspaces to Pro (then an account may own 25) or write to the Logu team if it is an agency or consultant. Humans only: an API key can't create workspaces. Hint: an Owner can upgrade the workspace to Pro in Logu → Settings → Plan (on Pro, ask the Logu team to raise the quota); monthly counts reset on the 1st (UTC). Agents: tell your human, and don't retry until then. ## `ceiling_reached` HTTP 402. The workspace reached one of Logu's **cost ceilings**, which are always on and generous: asks (20,000), events (200,000) or metric reports (1,000,000) in a month, the same for what test API keys send (5,000 / 50,000 / 250,000), image uploads a month (20,000), the image bytes held (25 GB), projects (500), metric names (2,000), or the active API keys of one agent (20) (docs/billing.md has them all); also an account that already owns the most shared workspaces an account may own (25 with a workspace on Pro, or the number set for it). Upgrading doesn't lift a ceiling. Nothing was created: an ask over a ceiling was **not** asked, so an agent must not proceed as if a human had answered. The workspace's Owners are told once a month per meter (an event from Logu and an activity entry). Hint: tell your human, don't assume their answer, and don't retry before the 1st (UTC). The Logu team can raise a ceiling for one workspace. WhatsApp and email have ceilings too, but those never fail a request: the channel skips the send and the ask still lands in the app. ## `unauthorized` HTTP 401. No `Authorization` header, or the API key or session is invalid or revoked. Hint: send `Authorization: Bearer lg_…` with an active API key. Create an agent in Logu → Agents; its first API key is shown once. ## `forbidden` HTTP 403. The caller is authenticated but may not do this: an API key on a human-only endpoint (deciding an ask, managing agents, creating workspaces), or a member without the needed role (a Viewer deciding, a Decider managing webhook endpoints). On the sign-up, password sign-in and magic-link routes, a 403 means the Cloudflare Turnstile check wasn't completed or was refused (only on servers that use Turnstile): complete it and try again. Also `POST /v1/account/deletion` and `POST /v1/account/export` (deleting an account, exporting its data; [Your account and data](account.md)) need a session started in the last 15 minutes, and a sign-in to an account in its 30-day deletion grace by password or passkey is refused with a hint to ask for a magic link, which restores it. Hint: agents send asks, events and metrics and read their workspace; only a human Decider or Owner decides asks. Agents can never decide. For the account routes, sign in again (a magic link is quickest) and retry within 15 minutes. ## `workspace_suspended` HTTP 403. Logu suspended the workspace the API key (or connector) belongs to, usually for abuse or a violation of the terms. Nothing the agent sends is accepted, and nothing is delivered for the workspace (push, email, Telegram, Slack, WhatsApp, webhooks, callbacks) until it is reactivated. The workspace's members can still sign in, read what is there, export it and delete it. This is not something an agent can fix or retry past. Hint: tell your human. They can sign in at https://logu.dev to see the workspace and contact Logu support to ask for a review. ## `email_not_verified` HTTP 403. The signed-in human's email isn't verified yet, and the request needs it: accepting an invitation (otherwise anyone could register an invited address with a password and join in its place), creating a shared workspace, or becoming the Owner of one (a verified mailbox is what makes an account worth its own workspaces). Signing in with a magic link, or the confirmation link sent after a password sign-up, verifies it. Hint: confirm your email with the link Logu sends (the app offers it on the invitation page and in Settings → Account), then retry. ## `not_found` HTTP 404. No such route, or the ask, metric, agent or workspace doesn't exist in your workspace (an API key never sees another workspace's data, so those are "not found" too). Hint: check the path and the id; `GET /v1/me` shows which agent and workspace an API key is. ## `method_not_allowed` HTTP 405. The path exists, but not with this method (e.g. `DELETE /v1/events` or `PUT /v1/asks`). The `Allow` header lists the methods it takes. Hint: use one of the methods in the `Allow` header. ## `conflict` HTTP 409. The resource isn't in a state that allows this. Most often: the ask is no longer waiting (it was decided, timed out or withdrawn), so it can't be decided or withdrawn again; the body's `ask` is then the ask as it is now. Also: an invitation that is no longer pending, a member who already belongs to the workspace, removing or demoting the last Owner, redelivering a webhook delivery that is still `pending` or to a webhook endpoint that is turned off. And deleting your account while you are the only Owner of a shared workspace that has other members, or while a workspace that would be deleted with it has a live Pro subscription (the `hint` names the workspaces and what to do); an export asked while another is being prepared. Hint: read the current state instead of retrying; for an ask, `error.ask.status` and `error.ask.decision`. For account deletion, do what the hint says (transfer ownership or delete the workspace, cancel the subscription), then ask again. ## `idempotency_key_in_use` HTTP 409. Another request with the same idempotency key is still being processed. Hint: retry in a moment with the same key; you'll get that request's result. ## `idempotency_key_reused` HTTP 422. The idempotency key (`Idempotency-Key` header, or `idempotencyKey` in an ask) was already used by this agent in the last 24 hours with a different body or on a different endpoint. Hint: use a new key for a different request; reuse a key only to retry the exact same request. Keys are per agent and remembered for 24 hours. ## `rate_limited` HTTP 429. This API key sent too many requests. Limits are per API key, per minute: 60 asks, 600 events and metrics together, 1200 requests of any kind; and per workspace, all its API keys together: 3000 requests a minute (more keys don't raise it). A member may also start only 5 Checkout Sessions an hour. The response has `Retry-After` (seconds) and `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy` headers. Humans signed in to the app aren't limited, except for data exports (one a day). Unauthenticated endpoints (sign-in, `POST /v1/device/code`) are limited per IP address. Hint: wait `Retry-After` seconds, then retry. The SDKs do this for you. ## `authorization_pending` HTTP 400, from `POST /v1/device/token` only. The device code is valid but nobody has approved it in the app yet (`logu login` and the app's `/connect` page; see [Connecting agents](agents.md#connect-with-one-command)). Hint: keep polling every `interval` seconds (5 by default) until the human approves or denies it, or it expires. ## `slow_down` HTTP 400, from `POST /v1/device/token` only. The client polled sooner than `interval` seconds after its last poll. As in RFC 8628, add 5 seconds to the interval from now on. Hint: wait 5 seconds longer between polls, then poll again. ## `expired_token` HTTP 400, from `POST /v1/device/token` only. The device code expired (it lives 10 minutes), or its API key was already delivered: a device code gives out its API key exactly once. Hint: start again with a new device code (`logu login`). ## `access_denied` HTTP 403, from `POST /v1/device/token` only. The human chose Cancel on the `/connect` page. Hint: stop polling. Start again only if the human wants to connect this agent. ## `internal_error` HTTP 500. Something went wrong on Logu's side. Hint: retry with backoff. If it keeps happening, report the `requestId`. ## `overloaded` HTTP 503 with `Retry-After`. The workspace's realtime hub (the one Durable Object that holds long-polls and the app's streams) could not take this request, so it was shed instead of hanging: `GET /v1/asks/{id}/wait` and MCP waits. Creating asks, sending events and metrics, and deciding never depend on the hub and never answer this code: if the hub is down they still succeed (only the live updates are late). Over the hub's caps the long-poll is not an error: it answers 200 at once with the ask still `waiting` and a `Retry-After` header. Clients keep polling, no sooner than that header says (the SDKs, the CLI and the Python client already sleep at least one second between polls). Hint: retry after `Retry-After` seconds, with jitter. The ask is unchanged. ## `service_unavailable` HTTP 503. A dependency is briefly down (e.g. an ask's workflow couldn't start), or a feature isn't configured on this server (browser push, Telegram, Slack). Hint: retry with backoff (asks: with the same idempotency key). For a feature that isn't configured, ask the server's operator.