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
- The terminal shows a code (
WDJB-MJHT) and openshttps://logu.dev/connect?code=WDJB-MJHT(sign in first if you need to; you come back to it). - 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.
- 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%\loguon 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), orPOST /v1/agents/{id}/api-keyswith{"test": true}(POST /v1/agentstakestesttoo). - Check which one you have: the prefix says it, and
GET /v1/me(MCPwhoami,logu whoami) returnsapiKey.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.settledcallback 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
ordersnever moves the liveorders), 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_KEYfor 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, elseLOGU_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 shortkey: valuelines (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.updatedInputwith thequestionsandanswerskeyed 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=1to 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, elseLOGU_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 0turns closing questions back into events.- 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
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), andask_humanthroughlogu mcpwhen 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, orget_decision/withdraw_askshowing it settled). The hook withdraws them with its own profile's API key (claude-code, orcodex), 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 askknows (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'sCLAUDE_ENV_FILE(Claude Code's documented way for a hook to set variables for the session's Bash commands; it runs again on/clearand--resume, so it follows the session), elseCLAUDE_CODE_SESSION_ID, which Claude Code sets in the environment of the commands and MCP servers it starts. Both are thesession_idhooks get. Outside Claude Code neither is set andlogu askrecords nothing.logu mcpis started once per session, so it usesCLAUDE_CODE_SESSION_ID; after/clearits 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 = falseturns them off (install says so).config.toml—[mcp_servers.logu](command = "logu",args = ["--profile", "codex", "mcp"], or the same throughnpx -y @logu/cli;tool_timeout_sec = 120, aboveask_human's 50 s wait and Codex's 60 s default;startup_timeout_sec = 30for npx), replacing an earlier[mcp_servers.logu]. Andnotify, 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'sturn-endedkeeps 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 othertypeis 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 thecodexproject. A comment above it keeps the original line, whichuninstallputs back byte for byte.--no-notifyleaves notify alone.AGENTS.md(AGENTS.override.mdwhen that's the one Codex reads) — a short "Logu" section between<!-- logu:start … -->and<!-- logu:end -->: useask_human/logu askfor decisions only you can make while you're away,send_event/logu eventfor 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 andhint), so atimeout_secondstypo can't silently become a 24-hour ask with no default. - Idempotency. Send
Idempotency-KeyonPOST /v1/asks,/v1/eventsand/v1/metrics(asks also takeidempotencyKeyin the body). Retrying with the same key and body returns the first result with status 200 andIdempotent-Replayed: true; another body with the same key is a 422idempotency_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/eventsand/v1/activityreturn newest first, at mostlimititems, andnextCursor: send it back as?cursor=for the next page; it isnullat the end of the list. Cursors are opaque and belong to their list (another list's, or a made-up one, is a 400bad_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_limitedwithRetry-AfterandRateLimit-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): 402ceiling_reached. Both arePlanLimitErrorin the SDKs and aplan_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 (429rate_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/workspacesand 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'scallbackUrl(callback_urlin MCP and Python) still gets one signedask.settledPOST, 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.