LoguDocs
Open Logu

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#

  • Decision is { optionId, text, decidedVia, decidedAt }. An ask that expired and had its default applied is decided with decidedVia: "timeout"; timed_out means no default.
  • Analytics Engine binding INSIGHTS / dataset logu_insights: renaming would orphan history.
  • Signed-link key label logu-answer-links and 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 are evt_, act_, agt_.

Writing for people#

  • English UI first, every string through the message catalog (the app's messages/*.json; the API's notification copy in apps/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.