LoguDocs
Open Logu

Connecting agents

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:

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 <name> 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 <name> 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:

export LOGU_API_KEY=lg_...
# The API is https://api.logu.dev; when self-hosting, also:
# export LOGU_BASE_URL=https://<your-api>

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); 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.

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#

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 <host>"; 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#

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 has 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 (<file>.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 <host>"; 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 <host>". 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 program that was there>…]. 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 <!-- logu:start … --> and <!-- logu:end -->: 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):

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:

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:

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). 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#

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/<id>/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. 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.

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#

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#

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. 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.

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
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):

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):

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):

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. 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.