Logu glossary
The single source of truth for Logu's vocabulary. The same word is used in the API, the SDKs (TypeScript and Python), the CLI, the MCP tools, the database, the app, emails, push, Telegram, Slack, WhatsApp and the docs. A term is changed here first, then everywhere in one change: never in the app alone.
Agents ask. You decide.
Logu is where software agents reach a human. They send three kinds of things: asks (they need a decision), events (they tell you something happened) and metrics (they report a number).
Terms#
| Term | Meaning | Not |
|---|---|---|
| Agent | Anything that calls Logu from code: an AI agent, a script, CI, a cron job. Has a name and one or more API keys. Every ask, event and metric is attributed to the agent that sent it. | "Bot", "client", "integration" |
| API key | The credential an agent uses (Authorization: Bearer lg_…). Belongs to one agent; can be rotated and revoked. Live (lg_…) or in test mode (lg_test_…). Always called "API key" (never just "key" or "token" in UI copy). |
"Token" |
| Test mode | A test API key (lg_test_…) lets a developer build and try an integration without reaching anyone: its asks, events and metrics are marked test, shown in the app with a Test badge (pt-BR: Teste) and never delivered (no push, browser notifications, email, Telegram, Slack or WhatsApp). Test asks are still decided by a human in the app (or time out), and their callbacks are sent. A key that isn't a test one is live. |
"Sandbox", "dry run", "dev key" |
| Ask | An agent's request for a human decision: a title, optional body and context link, options (or free text), an expiry and a default. Noun and verb: an agent asks; the app lists asks. | "Question", "request", "approval" |
| Expires in | How long an ask stays open for a decision; then it times out (its default option is applied, or it ends timed_out). Set by the agent: wire field timeoutSeconds (timeout_seconds in Python), CLI --expires-in. Shown to the human as the expiry (expiresAt). |
"Timeout" in prose (the wire name stays) |
| Default option | The option an ask records as its decision when it expires with nobody having answered (wire field defaultOption, Python default_option, CLI --default; the decision has decidedVia: "timeout"). An agent may not make an approving option its default: the id approve, or any option with style: "primary" (the go-ahead). That is a 400 approving_default; use decline to fail closed, or no default. Whatever default applies on timeout is never an approval: clients check decidedVia, not only optionId. Pre-approval is an Owner's rule, not a default. |
"Fallback", "auto-approve" |
| Wait | How long a client waits for the decision: SDK WaitOptions.timeoutMs / guard({ wait }), Python wait=, CLI --wait, Claude Code LOGU_HOOK_WAIT / LOGU_HOOK_STOP_WAIT / --wait (install: --wait, --stop-wait), MCP wait_seconds. When it runs out the ask may still be waiting; guard(), requires_approval and logu ask --wait withdraw it; Claude Code's stop hook leaves its closing question open until the next prompt in the terminal. Never changes the ask's expiry. |
"Timeout" |
| Decision | What the human returns to an ask: the chosen option and/or text, who decided (decidedBy, a Member), when and where (app, link, telegram, slack, whatsapp, rule when an Owner's rule decided it, or timeout when the default was applied). The first decision wins. |
"Answer", "reply", "verdict" |
| Event | Something an agent reports that needs no action ("Deploy finished"). Shown in the inbox and delivered like an ask, without options. | "Notification" (that's how it reaches you), "log line" |
| Thread | The conversation or run an event or ask belongs to, as the agent says it: metadata.thread, a short string (a Claude Code session id, a Codex thread id; the CLI sends it, and older items carry the same value as metadata.sessionId). The app shows the events of one thread as one expandable group; without it, events from one agent, project and channel less than an hour apart. Not a field of its own: an agent that has no such id leaves it out. pt-BR: "conversa". |
"Session" (that's a sign-in), "conversation" in UI copy (the group says "N events") |
| Attachment | An image an agent shows with an ask or event (up to 4): a UI change's before/after screenshots, a chart. Uploaded first (POST /v1/attachments, id att_…), then listed in attachments; belongs to that one ask or event. Private to the workspace, shown through signed links that expire. CLI --image, SDK uploadAttachment (Python upload_attachment), MCP images / attachments. In UI copy, "image(s)". |
"File", "upload", "media" (in UI copy) |
| Notification level | What an agent's events do to people: asks_and_events (every event is delivered), asks_only (events are stored and shown in the app, only asks are delivered) or silent (events are stored and shown, never delivered; the explicit "mute this agent's events"). Stored on the agent (notificationLevel), set by Owners and Deciders for the whole workspace; asks are always delivered, whatever the level. New agents connected as claude-code or codex start at asks_only, every other agent at asks_and_events. A project mute is a member's own and also silences events. pt-BR: "nível de notificação". |
"Mute" for the level (a mute is a member's own, per project) |
| Metric | A named number an agent reports over time (orders = 42). |
"Insight" |
| Insights | The app section that turns metrics into trends and threshold alerts. A view, not a thing agents send. | |
| Metric history | A metric over a range (24h, 7d, 30d, 90d, 1y), grouped in buckets (average, min and max of the reports in each), with its min, max, average and change over the range, and the previous period (the same length, just before the range: previous, summarized the same way) to compare with. Comes from the long-term history (Analytics Engine, every live report) when the server has it set up, else from the last reports kept in Logu (source: "recent"). Test metrics only have the recent reports. GET /v1/metrics/{id}/history. pt-BR: "histórico". |
"Time series", "chart data" |
| Decision speed | How quickly a workspace's members decide over a range: asks decided by a human, asks that timed out, the median and 90th percentile time to a decision (live asks only). GET /v1/asks/stats. |
"SLA", "response time" |
| Threshold alert | A rule on a metric: tell me when it goes above or below a value. | "Alarm" |
| Operator workspace | The workspace named by the server's OPS_WORKSPACE_ID var, where Logu reports its own health as metrics of the project ops (ask_stuck, push_failed_5m, dlq_depth, …) with default threshold alerts, so the owner learns of a problem from their phone. Nothing is reported until the owner sets it. See docs/observability.md. |
"Admin workspace", "monitoring", "status page" |
| Project | The top-level grouping an agent sends to (shop, infra), inside a workspace. Each member can mute it for themselves; Owners set its routing and escalation. |
"Workspace", "app" |
| Routing | Who is notified of a project's asks: every Owner and Decider (the default) or the Owners and Deciders an Owner picked. Only changes notifications: everyone still sees the asks and decides them per role. Never nobody: if none of the routed members can be reached, the ask goes to every Owner and Decider. pt-BR: "notificações" (UI), "roteamento" (docs). | "Assignment", "channels" |
| Escalation | A project's "if an ask is still waiting after N minutes, also notify all Owners (or these members)". Once per ask; shown on the ask ("Escalated to Ana") and in the activity log. pt-BR: "escalar". | "Reminder" (that's before the timeout, to the same people), "page" |
| Rule | An Owner's decision made in advance: asks from one agent in one project (with an exact title, when set) that offer one option are decided with it as soon as they're sent, by that Owner (decidedVia: "rule", ruleId), and nobody is notified. Optional expiry and max uses. Only Owners create and delete rules, in the app; never an agent, API key or connector. pt-BR: "regra". |
"Auto-approve", "policy", "automation" |
| Account deletion | A member's request to delete their account (Settings → Account → Delete account). The account is disabled at once and deleted for good after the grace period of 30 days; asking for a magic link during it restores the account. In shared workspaces the person's records stay, shown as Removed member (pt-BR: "Membro removido"). Humans only. See docs/account.md. pt-BR: "excluir conta". | "Close account", "deactivate" |
| Data export | A ZIP of JSON files with what Logu holds about a member (profile, preferences, memberships, decisions, activity, personal workspace), built on request, downloaded with a signed link valid 24 hours, kept 7 days. pt-BR: "exportar meus dados". | "Backup", "download my data" |
| Channel | A sub-grouping inside a project (#releases, #support). Optional. |
"Topic", "stream". Never used for delivery methods. |
| Delivery | How Logu reaches you: push (phone), browser notifications, email, Telegram, Slack (a DM from the Logu Slack app), WhatsApp. Settings → Delivery shows each one's state (Connected, Off, Not set up on this server, Needs attention) and a test for the connected ones. | "Channels" |
| Customer service window | WhatsApp's 24 hours after a member's last message to Logu: inside it Logu sends free-form messages (reply buttons, lists, text); outside it only message templates. Tracked per member (whatsapp_contact.last_inbound_at). |
"Session" (that's a sign-in) |
| Message template | A WhatsApp message Meta approved in advance (fixed text with variables and buttons), the only kind Logu may send outside the customer service window: logu_ask_approve_decline, logu_ask, logu_event, logu_outcome, in en and pt_BR (apps/api/whatsapp-templates.json). |
"Template message", "HSM" |
| Quiet hours | A daily window when deliveries are held (events always; asks only if "Hold asks too"). | "Do not disturb" |
| Activity log | The record of who did what in a workspace: asks created and decided (and by whom), API keys created and revoked, members invited and joined, delivery changes. | "Audit log", "history" |
| Retention | How long Logu keeps history: 180 days (LIMITS.retentionDays). Asks are deleted 180 days after they settled and events 180 days after they were sent, with their images (attachments); activity log entries 180 days after they were recorded. Asks still waiting are never deleted; metrics keep their own short history. Applied by the daily maintenance. |
"TTL", "expiry" (that's an ask's), "archive" |
| Workspace | Owns everything agents send: projects, agents and their API keys, asks, events, metrics, threshold alerts and the activity log. Every human has a personal workspace (named after them) and can be a member of others. Humans pick one with the Logu-Workspace header; an agent is always in its API key's workspace. |
"Team", "organization", "account" |
| Member | A human in a workspace, with one role. Delivery settings, quiet hours, devices, Telegram, Slack, WhatsApp and project mutes stay per member, across workspaces. | "User" (in UI copy), "seat" |
| Owner | Role (owner): everything a Decider can do, plus members, invitations, renaming and deleting the workspace. A workspace always has at least one. |
"Admin" |
| Decider | Role (decider): decides asks and manages agents and API keys, projects and threshold alerts. Owners and Deciders get every ask, unless a project's routing picks some of them. |
"Editor", "approver" |
| Viewer | Role (viewer): sees asks, events, metrics and the activity log, but cannot decide and isn't notified of asks. |
"Read-only user" |
| Magic link | A one-time sign-in link sent by email (15 minutes). Opening it shows the account it signs in to; only the click signs in. It also creates the account, and resets a password. | "Login link", "email code" |
| Passkey | A WebAuthn credential (fingerprint, face or screen lock) that signs a human in. pt-BR: "chave de acesso". | "Security key" (for platform passkeys), "biometrics" |
| Session | A browser or device signed in to a human's account; listed and signed out in Settings → Account. | "Login", "token" (in UI copy) |
| Locale | The language Logu uses with a human: en or pt-BR (Locale in the protocol, user.locale, locale in PATCH /v1/preferences and GET /v1/me; for an API key, AgentMe.locale is the workspace's first owner's). The app sends its own (the effective one, also when it follows the device); push, browser notifications, Telegram, WhatsApp, emails and the Claude Code hooks follow it. Null means English. |
"Language code" in UI copy (say "language") |
| App link | A link into the app at one thing: https://logu.dev/asks/<id>, /events/<id>, /connect?code=… (with ?workspace=<id> when Logu knows it). Push, browser notifications, email, Telegram, WhatsApp, the decision link page and the CLI all use them (@logu/protocol links.ts). They open the installed app (iPhone, Android, the installed web app on a computer) at that screen, else the web app. See docs/deep-links.md. |
"Deep link" and "permalink" in UI copy |
| Connect | Giving an agent its API key with one click instead of copying it: logu login (or logu install claude-code / codex) shows a user code and opens the app's /connect page, where an Owner or Decider checks the code, picks a new agent or an existing one (agentId; the page suggests the one matching the client's client_kind) and clicks Connect; that hands a new API key for that agent to the CLI once. The same page and button connect a connector (OAuth). Verb and button label ("Connect an agent"). |
"Pair", "link", "authorize" (in UI copy) |
| Connector | An MCP client connected to Logu's MCP server with OAuth instead of an API key: a claude.ai or ChatGPT connector, Cursor, Claude Code over HTTP. The human signs in on /connect?request=…, picks the workspace and a new or existing agent, and clicks Connect. Listed among the agent's API keys (connector on ApiKey), its secret never issued; its access tokens (lga_…, 1 hour, /mcp only) and refresh tokens (lgr_…, 90 days, rotated) are stored as hashes. Disconnect on the Agents page, deleting the agent or POST /oauth/revoke ends it. pt-BR: "conector". |
"Integration", "OAuth app", "authorized app" |
| Cursor | Where the next page of a list starts: the nextCursor a list returns (GET /v1/asks, /v1/events, /v1/activity, MCP list_asks), sent back unchanged as cursor; null (absent in MCP) at the end of the list. Opaque to clients and tied to its list. SDK listAsks({ cursor }) / listAllAsks(), Python list_asks(cursor=) / list_all_asks(). |
"Page token", "offset", "next_cursor" (fields are camelCase) |
| Profile | A named API key saved on a computer by the CLI (~/.config/logu/config.json → profiles), one agent each: default (logu login), claude-code (logu install claude-code), codex (logu install codex). Picked with --profile <name> or LOGU_PROFILE, else the file's current, else default; LOGU_API_KEY wins over every profile. It's how each integration acts as its own agent. |
"Account", "context", "identity" |
| Device code | The secret a client holds while connecting (RFC 8628's device_code), never shown. Its user code ("WDJB-MJHT", 8 consonants) is what the human checks and approves. Both expire in 10 minutes and are stored only as hashes. Not a push "device" (a phone registered for push). |
"Pairing code", "PIN" |
| Webhook endpoint | A URL on a workspace's server that Logu POSTs to for the event types it subscribes to (ask.created, ask.settled, event.created, metric.alert; test sends are webhook.test), signed with Standard Webhooks headers. Belongs to the workspace; only Owners manage them (Settings → Webhooks, /v1/webhooks), up to 10. Id whk_. Each message to one endpoint is a webhook delivery (id whd_, sent as the webhook-id header, the same on every retry and redelivery). Always "webhook delivery" with the qualifier, never bare "delivery" (that's how Logu reaches humans). See docs/webhooks.md. |
"Hook", "subscription", "callback" (the callback URL is the legacy per-ask callbackUrl) |
| Signing secret | A webhook endpoint's own secret (whsec_…, Standard Webhooks format) that signs each webhook delivery. Independent of API keys; shown once on creation and on rotation, when the previous one keeps signing for 24 hours. |
"API key", "token", "webhook key" |
| Slack app | The one Logu app in Slack (distributed): each member adds it to their own Slack workspace from Settings → Delivery, and Logu DMs them their asks and events. Its Slack signing secret (SLACK_SIGNING_SECRET) verifies requests from Slack; always say "Slack signing secret", never bare "signing secret" (that's a webhook endpoint's). The connection a member makes is "connect Slack" / "Add to Slack" in UI copy. See docs/delivery.md#slack. |
"Slack bot", "Slack integration", "Slack channel" (for the DM) |
| Delivery log | A webhook endpoint's list of webhook deliveries and each attempt (time, status code, duration, the start of the response or the error), kept 30 days, with redeliver. Not the activity log, and not about notifications to humans. | "Webhook history", "event log" |
| Plan | What a workspace is on: Free or Pro. Per workspace (personal and team alike); its Owners pay for it. Every member sees it (Workspace.plan); Owners see the details in Settings → Plan. See docs/billing.md. |
"Tier" (that's a price), "subscription" (that's Stripe's), "account type" |
| Free | The plan every workspace starts on: 1 member (the Owner), 100 asks, 1,000 events and 10,000 metric reports a month, 2 agents, no webhook endpoints, 5 projects, 20 metric names, 200 MB of images, no WhatsApp template messages, 30 days of history in the app (FREE_PLAN_LIMITS, PLAN_QUOTAS.free). Over a quota, when enforced: 402 plan_limit. |
"Trial", "basic", "starter" |
| Pro | The paid plan: US$ 29/month per workspace (list price), members, webhook endpoints, the full history, and fair-use quotas (5,000 asks, 50,000 events, 250,000 metric reports a month, 20 GB of images, 100 WhatsApp template messages, 100 projects, 500 metric names: PLAN_QUOTAS.pro). Bought with Stripe Checkout (Upgrade), managed in Stripe's Customer Portal (Manage). A workspace Logu gives Pro at no charge is comped. |
"Premium", "paid tier", "business", "unlimited" |
| Quota | What a plan includes of one meter (asks, events, metric reports, image storage, WhatsApp template messages, projects, metric names), per workspace; PLAN_QUOTAS, times QUOTA_FACTOR per price tier. Enforced only when BILLING_ENFORCED is on: at 80% the Plan page warns and the Owners are told, at 100% the API answers 402 plan_limit (no overage billing yet). pt-BR: "cota". |
"Limit" alone (a cost ceiling is also a limit), "allowance", "cap" (that's what a delivery channel hits) |
| Cost ceiling | Logu's own, always-on bound on what one workspace can use of a meter in a month (or hold, or have): asks, events, metric reports, the same for test API keys, image uploads and storage, projects, metric names, API keys per agent, WhatsApp template messages and emails (COST_CEILINGS). Generous (no honest workspace meets one) and independent of BILLING_ENFORCED; over it the API answers 402 ceiling_reached (a delivery channel's ceiling skips the send and never blocks an ask). An operator can raise one for a workspace (limit_overrides, set by hand in D1). pt-BR: "teto de custo". |
"Rate limit" (that's per minute), "hard limit", "quota" (that's the plan's) |
| Owned workspace | A shared workspace in which a human is an Owner. An account may own WORKSPACE_LIMITS.ownedFree (3), or ownedPaid (25) when it owns one on Pro or comped; its personal workspace never counts, and being a Decider or Viewer of others never counts. Above the limit an account keeps what it has and can't create or take more. Creating one also needs a verified email and is limited to createdPerDay (5) a UTC day. A hand-set row in account_limit lifts the numbers for an agency. See docs/billing.md#abuse-protection. |
"Seat", "license", "team limit" |
| Global daily cap | A limit on the whole service for one UTC day, not on a person: new accounts (SIGNUPS_PER_DAY_MAX, 1,000) and sign-in emails to addresses with no account or no verified email (AUTH_EMAILS_PER_DAY_MAX, 5,000). Reaching one answers a calm 429 rate_limited and alerts the operator (signups_capped_5m, login_emails_capped_5m); accounts that already exist are never affected. |
"Rate limit" (that's per minute, per IP or per key), "quota" (that's the plan's) |
| Turnstile | Cloudflare's free captcha on password sign-up, password sign-in and magic-link requests. On only while the API has TURNSTILE_SECRET_KEY and the app was built with the public VITE_TURNSTILE_SITE_KEY; the token travels in the Turnstile-Token header. A 403 forbidden on those routes means the check was not completed. Never asked of an agent or of a connect flow. |
"Captcha" in copy shown to people (say "check that you're a person") |
| Meter | One thing a workspace is counted on, shown on Settings → Plan with what it used, its quota and its ceiling, and its state (ok, warning from 80%, full). Billing.meters. |
"Counter" (that's ops' ops_counter), "gauge" |
| Capped channel | A delivery channel (WhatsApp template messages, email) that skipped a send because its cap was reached this month. The ask is never blocked: it lands in the app and on every other delivery. Recorded as delivery.capped, counted as delivery_capped_5m, and shown to Owners on Settings → Plan (Billing.deliveryCapped). |
"Blocked", "disabled" |
| Founder price | Pro's price for the first workspaces that subscribe: US$ 9/month for the first 100, US$ 19/month up to 1,000 (PRICING.founderTiers). Locked for as long as that subscription stays active; cancel and come back and it's the current price. Each is a founder slot, held while a checkout is open. |
"Discount", "early bird", "coupon" |
| Invitation | An Owner's invitation for an email address to join a workspace with a role: a one-time link, valid 7 days, accepted by signing in with that email. One pending per email per workspace. | "Invite code" |
Ask states#
| State | Meaning |
|---|---|
waiting |
Sent and not yet decided. |
decided |
A human chose (or the default was applied via timeout, see decidedVia). |
timed_out |
Expired with no decision and no default. Shown as "Timed out". |
withdrawn |
The agent took the ask back before anyone decided. |
settled is the umbrella for any final state (decided, timed_out, withdrawn); it names the
webhook and the stream message, not a state.
Default options are Approve (approve, primary) and Decline (decline, danger). With
their default labels they are Logu's words, shown in the reader's language (pt-BR: Aprovar,
Recusar; defaultOptionKey in the protocol); labels an agent wrote are always shown as sent.
Outcomes in words#
How a settled ask reads in push, browser notifications, Telegram, Slack and WhatsApp (one pattern,
<who> decided: <choice>; no ✓ or •):
| en | pt-BR | |
|---|---|---|
| Someone else decided | Ana decided: Approve | Ana decidiu: Aprovar |
| You decided | You decided: Approve | Você decidiu: Aprovar |
| Default applied on timeout | Timed out: Decline applied | Expirou: Recusar aplicado |
timed_out |
Timed out with no answer | Expirou sem resposta |
withdrawn |
Withdrawn by the agent | Retirado pelo agente |
| Reminder (title prefix) | Reminder: … | Lembrete: … |
Identifiers (renamed before 1.0)#
Nothing below had shipped in a public 1.0, so the rename has no compatibility layer. The legacy
pylogu 0.2.x stays on PyPI for the 2024 app.
HTTP API (/v1)#
| Before | After |
|---|---|
POST /notify, GET /notifications |
POST /events, GET /events |
POST /insights, GET /insights, DELETE /insights/:id |
POST /metrics, GET /metrics, DELETE /metrics/:id |
POST /insights/:id/alerts, DELETE /insights/alerts/:alertId |
POST /metrics/:id/alerts, DELETE /metrics/alerts/:alertId |
POST /asks/:id/answer |
POST /asks/:id/decision |
POST /asks/:id/cancel |
POST /asks/:id/withdraw |
GET /asks/:id/wait |
unchanged |
GET /api-keys, POST /api-keys, DELETE /api-keys/:id |
GET /agents, POST /agents (creates the agent and its first API key), POST /agents/:id/api-keys, DELETE /agents/:id/api-keys/:keyId, DELETE /agents/:id |
GET /audit |
GET /activity |
Protocol (@logu/protocol)#
| Before | After |
|---|---|
NotifyInput, Notification |
EventInput, Event |
InsightInput |
MetricInput |
Answer, AnswerInput, AnswerVia |
Decision, DecisionInput, DecidedVia |
AskStatus: pending / answered / expired / cancelled |
waiting / decided / timed_out / withdrawn |
DEFAULT_ASK_OPTIONS: approve / reject |
approve / decline |
PUSH_CATEGORY_APPROVE_REJECT = LOGU_APPROVE_REJECT |
PUSH_CATEGORY_APPROVE_DECLINE = LOGU_APPROVE_DECLINE |
Stream notification.created |
event.created |
Audit ask.answered / ask.cancelled / ask.expired |
ask.decided / ask.withdrawn / ask.timed_out |
| — | Audit agent.created, agent.updated, agent.deleted (alongside api_key.created / api_key.revoked) |
AuditEvent, AUDIT_ACTIONS |
ActivityEntry, ACTIVITY_ACTIONS |
| — | agent ({ id, name }) on Ask, Event and Metric |
SDKs#
TypeScript (@logu/sdk) |
Python (pylogu) |
|---|---|
notify() → event() |
notify() → event() |
insight() → metric() |
insight() → metric() |
ask(), createAsk(), getAsk() unchanged |
ask(), create_ask(), get_ask() unchanged |
waitForAnswer() → waitForDecision() |
wait_for_answer() → wait_for_decision() |
cancelAsk() → withdrawAsk() |
cancel_ask() → withdraw_ask() |
guard() unchanged (fails closed with decline) |
requires_approval() / approved() unchanged (fail closed with decline) |
| — | ask(wait_timeout=) / wait_for_decision(timeout=) → wait= (see Wait) |
— → listAsks() |
— → list_asks() |
— → listAsks({ cursor }) returns nextCursor; listAllAsks() |
— → list_asks(cursor=) returns nextCursor; list_all_asks() |
LoguError + ValidationError, AuthenticationError, PermissionError, NotFoundError, ConflictError, RateLimitError, ConnectionError |
LoguError + ValidationError, AuthenticationError, PermissionDeniedError, NotFoundError, ConflictError, RateLimitError, LoguConnectionError (no shadowing of Python built-ins) |
CLI (@logu/cli) and MCP#
| Before | After |
|---|---|
logu notify |
logu event |
logu insight |
logu metric |
logu ask, logu hook |
unchanged |
logu ask --timeout |
logu ask --expires-in (--timeout kept as an alias) |
| — | logu uninstall claude-code, logu ask --wait, logu hook claude-code … --wait |
| — | logu hook claude-code user-prompt, logu install claude-code --stop-wait |
| — | logu hook claude-code session-start (env LOGU_CLAUDE_SESSION_ID) |
MCP ask_human, report_metric |
unchanged |
MCP get_answer |
get_decision |
MCP cancel_ask |
withdraw_ask |
| — | MCP send_event |
MCP ask_human question, details, timeout_seconds |
title, body, expires_in_seconds (the old names stay as aliases) |
| — | MCP list_asks, whoami; idempotency_key on ask_human, send_event, report_metric |
MCP list_asks before → nextBefore |
cursor → nextCursor (before / nextBefore still work) |
Database (D1 migration, data kept)#
| Before | After |
|---|---|
table notification |
event |
ask.answer_option_id, answer_text, answer_via, answered_at |
decision_option_id, decision_text, decided_via, decided_at |
ask.status values |
mapped as in "Ask states"; option id reject → decline in stored options and decisions |
table audit_event |
activity; action names mapped as in Protocol |
| — | table agent (id, user_id, name, created_at); api_key.agent_id; agent_id on ask, event, metric_point. Existing API keys each become an agent named after the key. |
preference.email_asks, quiet_asks |
unchanged |
Workspaces (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP header | Logu-Workspace: <workspace id> (humans; default: the personal workspace). The stream takes ?workspace=. |
| HTTP API | GET/POST /workspaces, PATCH/DELETE /workspaces/:id, GET /workspaces/:id/members, PATCH/DELETE /workspaces/:id/members/:userId, GET/POST /workspaces/:id/invitations, DELETE /workspaces/:id/invitations/:invitationId, POST /workspaces/:id/invitations/:invitationId/resend, POST /invitations/accept |
| Protocol | Workspace, WorkspaceRole (owner / decider / viewer), Member, MemberRef ({ id, name }), Invitation, InvitationStatus (pending / accepted / revoked / expired), Decision.decidedBy (a MemberRef, null for timeout), PushData.workspace, WORKSPACE_HEADER |
| Activity | workspace.created, workspace.renamed, member.invited, member.joined, member.role_changed, member.removed, invitation.revoked; a human actor carries user (MemberRef) |
| Database | tables workspace, membership, invitation, project_mute; workspace_id on project, agent, api_key, ask, event, metric, metric_point, metric_alert, activity (replacing their user_id); ask.decided_by / decided_by_name; agent.created_by, api_key.created_by; activity.actor_id is the user for humans. project.muted moved to project_mute. |
| Ids | wsp_ (workspace; a migrated personal workspace is wsp_<user id>), inv_ (invitation) |
Sign-in (added in 1.x)#
| Where | Identifier |
|---|---|
| HTTP API (app only) | POST /api/auth/magic-link, /magic-link/peek, /magic-link/verify; POST /api/auth/passkeys/sign-in/options, /passkeys/sign-in, /passkeys/register/options, /passkeys/register, /passkeys/:id/rename, /passkeys/:id/delete; GET /api/auth/passkeys, /methods, /sessions; POST /api/auth/verify-email, /password, /sessions/:id/revoke, /sessions/revoke-others; Better Auth's /sign-up/email, /sign-in/email, /sign-out |
| Protocol | MagicLinkRequestInput, MagicLinkIntent (sign-in / sign-up / reset / verify), VerifyEmailInput, MagicLinkPeek, MagicLinkVerified, PasskeyInfo, SignInMethods (emailVerified, also on a human's GET /v1/me), SessionInfo, SetPasswordInput, MAGIC_LINK_TTL_SECONDS |
| Activity | sign_in_method.added, sign_in_method.removed (data.method: passkey / password), session.revoked |
| Database | table passkey; session.last_seen_at; magic links in verification (magic-link:<sha256>) |
| App | /auth/magic (the link's page), Settings → Account (passkeys, password, sessions) |
API conventions (added in 1.x, backward compatible for well-formed requests)#
| Where | Identifier |
|---|---|
| Request bodies | Strict: an unknown field is a 400 unknown_field (with "did you mean"). Telegram's webhook and Slack's requests stay lenient. |
| HTTP headers | Idempotency-Key (request; asks also take idempotencyKey), Idempotent-Replayed and Logu-Request-Id (response), Retry-After and RateLimit-Limit / -Remaining / -Reset / -Policy on 429s |
| Errors | { error: { code, message, hint, docUrl, requestId, issues?, ask? } }; codes in ERROR_CODES (protocol) and docs/errors.md: bad_request, unknown_field, unauthorized, forbidden, not_found, method_not_allowed, conflict, idempotency_key_in_use, idempotency_key_reused, rate_limited, internal_error, service_unavailable, email_not_verified, plan_limit, ceiling_reached, approving_default (http_error is gone) |
| Protocol | ERROR_CODES, ErrorCode, errorDocUrl, RATE_LIMITS, IDEMPOTENCY_HEADER, REQUEST_ID_HEADER, AgentMe, AskOptionInput, Ask.defaultOption, isApprovingOption, approvingDefault, isApproved |
| HTTP API | GET /v1/me also answers API keys (AgentMe); GET /llms.txt, GET /llms-full.txt |
| OpenAPI | GET /openapi.json (OpenAPI 3.1, committed as apps/api/openapi.json) and GET /docs; security schemes apiKey (agents) and session (humans); tags Asks, Events, Attachments, Metrics, Agents, Workspaces, Members, Invitations, Webhooks, Billing, Rules, Activity, Delivery, Me, Sign-in, Meta; operationIds are camelCase verbs (createAsk, waitForAsk, decideAsk, sendEvent, reportMetric…); extensions x-agent-hint (for LLM agents: when to call, whether it blocks) and x-logu-access (the access rule: { human, agent }) |
| Database | table idempotency_key (per workspace and agent, 24 h); ask.idempotency_key is kept as a record without its unique index |
Connecting an agent (added in 1.x)#
| Where | Identifier |
|---|---|
| HTTP API | POST /v1/device/code, POST /v1/device/token (no credentials; RFC 8628 field names: client_name, client_kind (claude-code / codex / cli), device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval, api_key); GET /v1/device/:userCode, POST /v1/device/approve, POST /v1/device/deny (humans; camelCase userCode, agentName for a new agent or agentId for an existing one, clientKind, existing) |
| Errors | authorization_pending, slow_down, expired_token, access_denied (from POST /v1/device/token) |
| Protocol | DEVICE_AUTHORIZATION, normalizeUserCode, DeviceClientKind, DeviceCodeInput, DeviceCode, DeviceTokenInput, DeviceToken, DeviceRequest, DeviceApproveInput, DeviceApproved, DeviceDenyInput; access rules may be public (x-logu-access.public) |
| Activity | agent.connected (data: name, client, existing); agent.created (new agents only) and api_key.created carry data.via: "connect" |
| Database | table device_authorization (hashes of the device code and the user code, status pending / approved / denied / delivered, client_kind, existing_agent) |
| Ids | dva_ |
| CLI | logu login, logu logout, logu whoami, logu profiles; the API keys are saved as profiles in ~/.config/logu/config.json ($XDG_CONFIG_HOME/logu, %APPDATA%\logu); lookup order LOGU_API_KEY > --profile > LOGU_PROFILE > the file's current > default. install claude-code / install codex use the claude-code / codex profiles (logu --profile <name> … in their hooks, notify and MCP server). logu mcp: the MCP tools over stdio (a bridge to /mcp), registered by logu install claude-code as Claude Code's MCP server logu |
| App | /connect?code=WDJB-MJHT |
Attachments (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP API | POST /v1/attachments (raw image body, ?name=; or multipart file), GET /v1/attachments/:id; attachments: ["att_…"] on POST /v1/asks and /v1/events; attachments: [Attachment] on every ask and event; signed links GET /a/:id?exp=…&sig=… (outside /v1) |
| Protocol | Attachment, UploadedAttachment, AttachmentId, AttachmentIds, ATTACHMENT_CONTENT_TYPES, ATTACHMENT_LIMITS (in LIMITS), RATE_LIMITS.attachmentsPerMinute, sniffImageType, imageSize, imageTypeForName; WebPushPayload.image |
| SDKs | TypeScript uploadAttachment(file, contentType?, { name }); Python upload_attachment(data, content_type=None, name=None); attachments on ask and event |
| CLI and MCP | --image file (repeat) on logu ask and logu event; MCP images: [{data_base64, content_type, name}] and attachments on ask_human and send_event (path instead of data_base64 through logu mcp) |
| Database | table attachment (ask_id or event_id, position, r2_key, sha256); R2 bucket logu-attachments (binding ATTACHMENTS), keys <workspace id>/<attachment id> |
| Ids | att_ |
| App | "image(s)" in UI copy |
Webhook endpoints (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP API (Owners) | GET/POST /v1/webhooks, GET/PATCH/DELETE /v1/webhooks/:id, POST /v1/webhooks/:id/rotate-secret, POST /v1/webhooks/:id/test, GET /v1/webhooks/:id/deliveries, GET /v1/webhooks/:id/deliveries/:deliveryId, POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver |
| HTTP headers (on each webhook delivery) | webhook-id, webhook-timestamp, webhook-signature (Standard Webhooks); an ask's callbackUrl POST keeps logu-signature and adds webhook-id: cbk_<ask id> |
| Protocol | WEBHOOK_EVENT_TYPES, WebhookEventType, WebhookDeliveryType, WebhookEndpoint, WebhookEndpointWithSecret, CreateWebhookEndpointInput, UpdateWebhookEndpointInput, WebhookDelivery, WebhookDeliveryDetail, WebhookDeliveryStatus (pending / succeeded / failed), WebhookAttempt, WebhookMetric, WebhookPayload, WebhookUrl, isPublicWebhookHost, STANDARD_WEBHOOK_HEADERS, WEBHOOK_SECRET_PREFIX, generateWebhookSecret, signStandardWebhook, verifyStandardWebhook, LIMITS.maxWebhookEndpoints |
| SDKs | TypeScript verifyStandardWebhook, STANDARD_WEBHOOK_HEADERS, WEBHOOK_SECRET_PREFIX, types WebhookPayload, WebhookEventType; Python verify_standard_webhook, WEBHOOK_ID_HEADER, WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_SECRET_PREFIX |
| Activity | webhook.created, webhook.updated, webhook.deleted, webhook.secret_rotated, webhook.redelivered, webhook.disabled (by Logu, after 3 days of failures) |
| Database | tables webhook_endpoint (secrets encrypted), webhook_delivery, webhook_attempt (30 days); Workflow logu-webhook (binding WEBHOOK_WORKFLOW), one per webhook delivery |
| Ids | whk_ (webhook endpoint), whd_ (webhook delivery), whsec_ (signing secret) |
| App | Settings → Webhooks (/webhooks, Owners) |
Test mode (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| API keys | prefix lg_test_ (live keys stay lg_); ApiKey.test, NewApiKey.test, AgentMe.apiKey.test; test on CreateAgentInput, CreateApiKeyInput and DeviceCodeInput (DeviceRequest.test); protocol API_KEY_PREFIX, TEST_API_KEY_PREFIX, isTestApiKey |
| Protocol | test: boolean on Ask, Event and Metric (also in the stream, the ask.settled webhook and MCP results, where it's only present when true) |
| Activity | data.test: true on what a test API key did (ask.created, ask.withdrawn), on a test ask's transitions and on api_key.created / agent.connected for a test API key |
| Database | test on api_key, ask, event, metric (unique index metric_project_id_name_test_idx: a test metric is its own row) and device_authorization |
| CLI | logu login --test |
| App | The Test badge (flask icon); the inbox's ?test=hide ("Hide test"); Insights' "Test metrics" section; Agents → "Add a test API key" |
Plans and billing (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP API | GET /v1/billing, POST /v1/billing/checkout, POST /v1/billing/portal (Owners), GET /v1/billing/pricing (public); Stripe's webhook POST /stripe/webhook (outside /v1) |
| Errors | plan_limit (402, a plan quota, or a Free account at its 3 owned workspaces), ceiling_reached (402, a cost ceiling, or an account at the most it may own); SDKs PlanLimitError (TypeScript and Python) for both; email_not_verified (403) also for creating a workspace |
| Protocol | Plan (free / pro), PLANS, FREE_PLAN_LIMITS, PRICING, PriceTier (founder_9 / founder_19 / pro_29), tierForSlot, tierPriceUsd, usagePeriod, Usage, Subscription, Billing, Pricing, PricingTier, BillingRedirect, Workspace.plan; COST_CEILINGS, PLAN_QUOTAS, QUOTA_FACTOR, QUOTA_WARNING_RATIO, WHATSAPP_CREDIT_PACK, LOGU_PROJECT, quotasFor, effectiveLimits, LimitOverrides, parseLimitOverrides, Meter, METER_NAMES, meterState; WORKSPACE_LIMITS, GLOBAL_DAILY_CAPS, AccountLimitOverrides, parseAccountLimitOverrides, ownedWorkspaceLimit; RATE_LIMITS.workspaceRequestsPerMinute, checkoutsPerHour; Billing.meters, Billing.deliveryCapped |
| Activity | billing.checkout_started, billing.subscribed, billing.updated, billing.canceled, billing.payment_failed, billing.quota_warning, billing.quota_reached, billing.ceiling_reached (system; data: meter, used, limit), delivery.capped (system; data: channel, reason), workspace.create_refused (human, in the account's personal workspace; data: reason) |
| Database | tables workspace_subscription (comped and limit_overrides set by hand), founder_slot (user_id), workspace_usage (per calendar month, UTC: asks, events, metrics, test_asks, test_events, test_metrics, whatsapp, emails, images), workspace_storage, workspace_notice, member_counter (kinds emails, checkout, workspaces), global_counter (daily), account_limit (hand-set), stripe_event (30 days) |
| Rate limiter | binding WORKSPACE_RATE_LIMITER (namespace 4109) |
| Ops | counters delivery_capped, ceiling_hit, signups_capped, login_emails_capped; metrics delivery_capped_5m, ceiling_hit_5m, signups_capped_5m, login_emails_capped_5m, signups_1d, auth_emails_1d |
| Config | vars BILLING_ENFORCED, SIGNUPS_PER_DAY_MAX, AUTH_EMAILS_PER_DAY_MAX, STRIPE_PRICE_FOUNDER_9, STRIPE_PRICE_FOUNDER_19, STRIPE_PRICE_PRO_29; secrets STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, TURNSTILE_SECRET_KEY; app build variable VITE_TURNSTILE_SITE_KEY |
| Command | pnpm --filter @logu/api billing:preflight (read-only list of Free workspaces over a quota, before BILLING_ENFORCED) |
| App | Settings → Plan (/settings#plan, Owners): Upgrade, Manage; the landing page's pricing section |
Delivery tests (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP API (humans only) | POST /v1/delivery/test (testDelivery; 5 a minute per member); GET /v1/push/config also returns phones (how many phones the member registered) |
| Protocol | DELIVERIES, DeliveryName (push / browser / email / telegram / slack / whatsapp), TestDeliveryInput, TestDeliveryResult (result: sent / not_connected / unavailable / failed), TEST_DELIVERY_RESULTS, RATE_LIMITS.deliveryTestsPerMinute |
| Activity | delivery.tested (data: delivery) |
| Push | job type test (apps/api/src/push/test.ts): a pretend ask with an id test_… that is never stored |
| App | Settings → Delivery (/settings/delivery): a state and Send a test per delivery, "Asks reach you by: …" and the quiet-hours line |
Routing, escalation and rules (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP API (Owners) | PUT /v1/projects/:id/routing ({ notify, escalation }); GET/POST /v1/rules, DELETE /v1/rules/:id. GET /v1/projects returns routing to members (not to API keys) |
| Protocol | ProjectRouting (notify: user ids or null, escalation), Escalation ({ afterMinutes, to: "owners" } or { afterMinutes, to: "members", members }), UpdateProjectRoutingInput, Project.routing; Ask.escalation (AskEscalation: { at, to: MemberRef[] }); DecidedVia + rule, Decision.ruleId; Rule, RuleStatus (active / expired / used_up / inactive), CreateRuleInput; LIMITS.maxRules, maxRoutedMembers, maxEscalationMinutes |
| Activity | ask.escalated (system; data: title, to (names), afterMinutes, notified), project.routing_changed, rule.created, rule.deleted; ask.decided with data.via: "rule" and data.ruleId (actor: the rule's Owner); ask.created with data.routing: "fallback" when no routed member could be reached |
| Database | project.notify, escalate_after_minutes, escalate_to; ask.recipients, escalated_at, escalated_to, rule_id; table rule |
| Workflow | logu-ask steps wait-before-escalation and escalate (before the reminder when it comes first) |
| Ids | rul_ |
| App | Settings → Projects → Routing (Owners, shared workspaces), Settings → Rules (/rules, Owners); the ask's "Escalated to" and "Decided by a rule set by Ana" |
Asks like this one (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP API (members) | GET /v1/asks/:id/history → { history: AskHistoryEntry[] }; agents can't call it, so it isn't an MCP tool |
| Protocol | AskHistory, AskHistoryEntry (optionId, label, count (humans), automated (rule or timeout), lastAt) |
| Database | index ask_history_idx on ask (workspace_id, project_id, agent_id, title) |
| App | the decision block's "No answer in 11h 58m → Decline" and "You've approved this 7 times · last 2 days ago"; the list row's one-line body preview; an Owner's "Always choose Approve for this?" toast and dialog (a rule, with Undo) |
Insights history (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| API | GET /v1/metrics/{id}/history?range= (getMetricHistory), GET /v1/asks/stats?range= (getDecisionSpeed) |
| Protocol | INSIGHTS_RANGES, InsightsRange (24h / 7d / 30d / 90d / 1y), INSIGHTS_RANGE_SECONDS, MetricHistory, MetricHistorySource (analytics / recent), MetricBucket, MetricSummary, DecisionSpeed |
| Secrets | CF_ACCOUNT_ID and CF_ANALYTICS_TOKEN (Account Analytics: Read), for the Analytics Engine SQL API |
| App | /insights/<metric id>: the metric's chart (with the previous period's average and "View as table"), range picker and summary; Insights' decision speed range picker |
Account deletion and data export (added in 1.x, backward compatible)#
| Where | Identifier |
|---|---|
| HTTP API (humans only) | GET /v1/account, POST /v1/account/deletion, POST /v1/account/export; signed download GET /x/{id}?exp=&sig= (outside /v1); magic link intent restore |
| Protocol | ACCOUNT_DELETION (graceDays: 30, reminderDaysBefore: 7), DATA_EXPORT, PRIVACY_CONTACT_EMAIL, REMOVED_MEMBER_NAME, AccountOverview, DeletionBlocker, DataExportStatus, AccountDeletionRequested, RequestAccountDeletionInput; MagicLinkPeek.deletionPurgeAt |
| Activity | account.deletion_requested, account.restored, account.exported (in the personal workspace) |
| Database | user.deletion_requested_at, deletion_reminder_sent_at; membership.suspended; table data_export; R2 prefix exports/<user id>/; the registry ERASURE_MAP (apps/api/src/services/erasure-map.ts) |
| Ids | exp_ |
| Docs | docs/account.md (users); docs/legal/ (internal drafts for a lawyer: data map, privacy policy, terms, runbooks) |
Kept on purpose#
Decisionis{ optionId, text, decidedVia, decidedAt }. An ask that expired and had its default applied isdecidedwithdecidedVia: "timeout";timed_outmeans no default.- Analytics Engine binding
INSIGHTS/ datasetlogu_insights: renaming would orphan history. - Signed-link key label
logu-answer-linksand the/r/<token>path: links already sent keep working. - CLI env var
LOGU_NOTIFY_PERMISSION_PROMPTS(it is about Claude Code's own notifications). - Old ids keep their prefixes (
ntf_,aud_); new ones areevt_,act_,agt_.
Writing for people#
- English UI first, every string through the message catalog (the app's
messages/*.json; the API's notification copy inapps/api/src/lib/copy.ts), shipped in English and Brazilian Portuguese (see DESIGN.md, "Languages"). - Lead with the action: "3 asks waiting", "Approve", "Decline". Not "You have 3 pending asks".
- Say what happened, why, and what's next in errors. Never "Oops".
- Times are relative when recent ("2m", "1h"), absolute after a day.