LoguDocs
Open Logu

Reaching the human

An ask is only useful if the human sees it in time and can decide from wherever they are. Logu delivers it several ways; all of them settle the same ask, exactly once.

In a workspace with several members, every Owner and Decider gets each ask (unless its project's routing picks some of them), each through their own deliveries below, quiet hours and mutes; Viewers see asks in the inbox but aren't notified. Events reach every member who hasn't muted their project, unless the agent that sent them has a quieter notification level. The first decision wins; everyone else's notification (push, browser, Telegram, Slack; a reply in WhatsApp) is then replaced by "Ana decided: Approve" (in each member's language), the decider's own just goes away, and decision.decidedBy tells the agent who decided.

Where When Decide from it
Inbox (web and app) Always, live over /v1/stream Any option or free text
Push (iOS/Android) When a device is registered (push.md) Approve/Decline on the lock screen, or tap to open
Telegram When the human connects a chat in Settings → Delivery A button per option, or a reply in their own words
Slack When the human adds Logu in Settings → Delivery A button per option, or an answer in their own words
WhatsApp When the human connects their number in Settings → Delivery Reply buttons (up to 3) or a list, a template's quick replies, or a reply in their own words
Browser push When the human turns on Settings → Delivery → Browser notifications Buttons per option (asks with up to two options)
Email When the human turns on Settings → Delivery → Email and the server has email set up One-tap signed link per option
Reminder A quarter of the ask's lifetime before it times out (at most 1 hour before), by push, browser push, Telegram, Slack, WhatsApp and email (each if it's on) Same as the original

Every delivery of an ask names the agent that asks and says when it times out and what happens then ("deploy-bot asks · Times out in 2 hours · Decline if no answer"). Outcomes are marked ✓ only when the go-ahead was chosen (Approve, or a primary option); anything else gets a neutral •.

decision.decidedVia tells the agent where the decision came from: app, link, telegram, slack, whatsapp, rule (an Owner's rule decided it as it was sent; decision.ruleId), or timeout when the default option was applied.

Test your deliveries#

Settings → Delivery (/settings/delivery) lists every delivery (phone push, browser notifications, email, Telegram, Slack, WhatsApp), each with its state in words (Connected, Off, Not set up on this server, Needs attention when the browser blocked notifications) and, for the connected ones, Send a test. Above the rows, one line says what reaches the member ("Asks reach you by: phone, Telegram. Events: not delivered."), computed from what is connected, and another says whether quiet hours hold events or asks too. Both look only at the member's own settings, not at any per-agent level.

A test is a pretend ask, "Logu test · this is how an ask will look", rendered by the same code as a real one (the same renderer and transport per delivery: Approve and Decline buttons, signed links, Telegram's keyboard, Slack's blocks, WhatsApp's reply buttons) and sent only to the signed-in member's own delivery.

  • It is not an ask. No ask, event or message record is created, so there is nothing to decide: its buttons answer "This was only a test" (or open nothing), and the app link in it opens Settings → Delivery.
  • It ignores quiet hours. quietHoursIgnored says so when a real ask would have been held.
  • For the browser, the app also shows a notification from the browser itself, which proves the permission even if the push service can't be reached.
  • API: POST /v1/delivery/test with { delivery: "push" | "browser" | "email" | "telegram" | "slack" | "whatsapp" } answers { delivery, result, count, quietHoursIgnored }; result is sent, not_connected (nothing is set up for it), unavailable (this server doesn't send it) or failed. Humans only (an API key gets 403), at most 5 a minute per member (429 with Retry-After). It is recorded as delivery.tested in the activity log (data: the delivery, nothing else). Code: apps/api/src/push/test.ts, the push job type test.

Routing, escalation and rules#

Owners set these per project and workspace (spec: 2026-10-13-routing-design.md). None of them lets an agent decide.

  • Routing (Settings → Projects → Routing, PUT /v1/projects/:id/routing): notify lists the Owners and Deciders notified of the project's asks; null (the default) is every Owner and Decider. It only changes notifications: everyone still sees the asks and decides them per role. When an ask is sent, routed members who can't be reached right now (no delivery on: no phone, browser, Telegram, or email when the server sends it; or holding asks in quiet hours) are skipped, and if nobody is left the ask goes to every Owner and Decider (ask.created records data.routing: "fallback"). The members it went to are stored on the ask (ask.recipients): its reminder and its outcome go to them. Test asks aren't delivered, so they aren't routed.
  • Escalation (same dialog, escalation in the same PUT): if an ask is still waiting afterMinutes after it was sent, also notify all Owners or chosen Owners and Deciders, once. The ask's Workflow plans it (plan), waits for it (wait-before-escalation, before the reminder when it comes first), then runs escalate: a no-op if the ask settled, else it adds the members not yet notified to the ask's recipients, sends them the ask (push, browser, Telegram, email), records ask.escalated and sets Ask.escalation ("Escalated to Ana"). Escalations due after the ask times out aren't planned. If none of the chosen members can decide anymore, it goes to all Owners.
  • Rules (Settings → Rules, GET/POST /v1/rules, DELETE /v1/rules/:id, Owners only, never API keys, connectors or MCP): an Owner's decision made in advance for asks from one agent in one project (with an exact title, when set) that offer one option, optionally until an expiry and for a number of uses. A matching ask is settled through settleAsk as soon as it's created: decidedVia: "rule", decidedBy the Owner who set the rule, ruleId; the activity log has ask.decided by that Owner with data.via: "rule". Nobody is notified, and the agent gets the decided ask in the POST /v1/asks response (and the callback, webhooks and stream as usual). The first decision wins: a rule that loses a race to a human gives its use back. A rule decides nothing once its creator is no longer an Owner. Rules apply to test asks too.
  • Asks like this one (GET /v1/asks/:id/history, members only, not agents, so not MCP): how members decided earlier asks from the same agent, in the same project, with exactly the same title, per option: count (decisions by a human: app, decision link, Telegram, Slack, WhatsApp), automated (the same option chosen by a rule or by the default on timeout, kept apart so "you" stays honest) and lastAt. Test asks and the ask itself aren't counted; settled asks are kept for the retention period, so it only reaches that far back. The app shows it in the decision block ("You've approved this 7 times · last 2 days ago"). When an Owner decides an ask in the app and has chosen that same option at least 3 times before (and never another one), a non-blocking toast offers "Always choose Approve for this?": its dialog creates a rule through POST /v1/rules for that agent, project, exact title and option (no expiry; the Owner may set an expiry and a maximum number of uses), and the confirmation toast has Undo (DELETE /v1/rules/:id). Nothing is created without the Owner pressing "Add rule"; Deciders and Viewers aren't offered it.

Quiet hours and muted projects#

Deliveries (push, browser notifications, email, Telegram, Slack and WhatsApp) follow two rules each member sets for themselves in Settings. The inbox and the realtime stream always get everything.

Events Asks
Muted project Not delivered Still come through: an agent is waiting
Quiet hours Held Come through, unless Hold asks too is on
  • Quiet hours are a daily window in the human's time zone (taken from the device when they're saved). A start after the end spans midnight (22:00–07:00).
  • Outcome pushes (ask-settled) always go out: they're silent and clear a notification that may already be on screen.
  • Held asks are not re-sent when quiet hours end. They wait in the inbox, and the reminder before the ask times out goes out if it falls outside quiet hours.
  • API: PATCH /v1/preferences with { quietHours: { start, end, timeZone } | null, quietAsks }, and PATCH /v1/projects/:id with { muted }. Only humans can change them. The policy lives in apps/api/src/services/policy.ts.

Notification level per agent#

An agent that reports "turn finished" every few minutes (Claude Code, Codex) would fill every lock screen. Each agent has a notification level, set for the whole workspace by Owners and Deciders (Agents page, or "Mute this agent's events" on an event or an ask), that decides what its events do:

Level Events Asks
asks_and_events Delivered (push, browser, Telegram, Slack, WhatsApp) Delivered
asks_only Stored and shown in the app, never delivered Delivered
silent Stored and shown in the app, never delivered Delivered
  • Asks are never silenced: an agent waiting for a decision always reaches the humans, whatever the level, through each member's own deliveries, quiet hours and mutes.
  • Existing agents are asks_and_events (nothing changed when levels shipped). A new agent connected with logu install claude-code or logu install codex (the device flow with client_kind claude-code or codex) starts at asks_only, because coding agents report often and the asks are what need you. Every other agent starts at asks_and_events. asks_only is what a coding agent starts with; silent is the explicit "mute this agent's events" choice: today they deliver the same, and the app words them differently.
  • It stacks with a member's own project mutes: an event is delivered only if its agent's level allows it and the member hasn't muted its project.
  • One rule, one place: eventRecipients (apps/api/src/services/workspaces.ts) returns nobody for an agent below asks_and_events, so push, browser notifications, Telegram, Slack and WhatsApp never see the event. Webhooks and the realtime stream still get it. Test events are never delivered anyway.
  • API: PATCH /v1/agents/:id with { notificationLevel } (Owners and Deciders; recorded as agent.updated in the activity log); notificationLevel is on every Agent.
  • The iOS lock screen groups an agent's notifications into one stack: Logu sets the APNs thread-id to the agent's id (the project's name when the sender is unknown).

In the app, the Events tab also collapses consecutive events from one agent in one project (within an hour of each other) into one row ("5 events from Codex"), which opens in place; both the Events and Decided tabs search; and an event's row menu (hover, or the detail on a touch screen) mutes its project for you or its agent's events for the workspace.

Telegram#

The human connects their own private chat with the Logu bot; asks and events then arrive there (following quiet hours and muted projects, like push).

  • Connecting: Settings → Delivery → Connect opens a one-time t.me/<bot>?start=<code> link (valid 10 minutes, stored as a SHA-256). Pressing Start sends /start <code> to the webhook, which binds that chat to the human. /stop disconnects. Group chats are ignored: anyone in a group could press the buttons.
  • Deciding: each option is an inline button (a:<askId>:<option index>, under Telegram's 64-byte limit). A press only counts from the linked person in their private chat, for one of their own asks. Asks that allow text say so and accept a reply to their message.
  • Outcome: when the ask settles (from anywhere), its Telegram messages are edited to show the outcome in place of the buttons. A button pressed after that says the ask is already decided.
  • Delivery runs in the push queue consumer: 429/5xx retry the job; a 403 (bot blocked) forgets the chat.

Setup (once per bot, or after rotating its token):

  1. Create a bot with @BotFather (/newbot) and copy its token.
  2. Run CLOUDFLARE_ACCOUNT_ID=… pnpm telegram:setup. It asks for the token (not echoed), stores TELEGRAM_BOT_TOKEN, a generated TELEGRAM_WEBHOOK_SECRET and TELEGRAM_BOT_USERNAME on logu-api, and points the bot's webhook at the API.
  3. In the app: Settings → Delivery → Connect.

Slack#

One Logu Slack app, distributed: each member adds it to their own Slack workspace, and Logu sends their asks and events as a direct message from Logu (following quiet hours, muted projects and test mode, like push; in the member's language). Spec: 2026-10-13-slack-design.md.

  • Connecting: Settings → Delivery → Add to Slack opens Slack's OAuth v2 page with the bot scopes chat:write and im:write (nothing that reads messages) and a one-time state (valid 10 minutes, stored as a SHA-256). Slack sends the member back to GET /slack/oauth, which exchanges the code, opens the DM with the installing Slack user and connects it; the app polls GET /v1/slack meanwhile. Org-wide (Enterprise Grid) installs are refused. Bot tokens are stored encrypted (AES-GCM, key derived from BETTER_AUTH_SECRET), one per Slack workspace.
  • Deciding: each option is a button (decide:<index>, value <askId>:<index>, in the option's style); asks that allow text add "Answer in words", which opens a modal. A press only counts from the connected member, in their own DM with Logu, for an ask they may decide (Owner or Decider). decision.decidedVia is slack.
  • Outcome: the pressed message shows the outcome at once (through the interaction's response_url), and when the ask settles from anywhere every Slack message sent for it is updated (chat.update) to show the outcome in place of the buttons.
  • Verification: POST /slack/interactions and POST /slack/events check Slack's signature (v0= HMAC-SHA256 of v0:<timestamp>:<body> with the app's signing secret, constant-time, timestamp within 5 minutes); anything else is a 401.
  • Leaving: Settings → Delivery → Disconnect stops delivery; when no member uses that Slack workspace anymore, Logu revokes its token. Uninstalling the app in Slack (app_uninstalled, tokens_revoked) disconnects every member there. Delivery runs in the push queue consumer: 429/5xx (and ratelimited) retry the job; a revoked token or an unreachable DM disconnects.
  • Without the three secrets below, GET /v1/slack says available: false and Settings says Slack isn't set up on this server.

Setup (once per server):

  1. Run pnpm slack:setup [api-url]. It prints the app manifest (slack-app-manifest.yml) for the API's URL with a link that creates the app from it, and the steps below.
  2. In the Slack app's settings, turn on public distribution (Manage Distribution).
  3. Store SLACK_CLIENT_ID, SLACK_CLIENT_SECRET and SLACK_SIGNING_SECRET (Basic Information → App Credentials) on logu-api: CLOUDFLARE_ACCOUNT_ID=… pnpm slack:setup [api-url] --store asks for them without echoing, or wrangler secret put each.
  4. In the app: Settings → Delivery → Add to Slack.

WhatsApp#

The member connects their own number; asks and events then arrive in WhatsApp (following quiet hours and muted projects, like push). Meta's WhatsApp Cloud API, no third party in between. Spec: superpowers/specs/2026-10-13-whatsapp-design.md.

  • Connecting: Settings → Delivery → Connect opens a one-time wa.me/<Logu's number>?text=Connect Logu <code> link (valid 10 minutes, stored as a SHA-256). WhatsApp opens with the message typed; sending it connects that number (WhatsApp says who wrote, so the number is proven, and the member writing first is free and needs no template). stop (parar, sair) disconnects, as does Disconnect in Settings.
  • The 24-hour window: WhatsApp allows free-form messages only for 24 hours after the member's last message to Logu (any message or tap). Inside it, an ask is an interactive message: reply buttons (up to 3, labels up to 20 characters) or a list (up to 6 options here, long labels in the row's description). Outside it, Logu may only send a message template Meta approved, and there are two, for an ask and its reminder: logu_ask_approve_decline (Approve / Decline quick replies) for asks with Logu's default options, else logu_ask with Show options (tapping it opens the window and Logu answers with the interactive message) and Open in Logu. Each is in en and pt_BR and ends with Open in Logu. Definitions: apps/api/whatsapp-templates.json. If Meta answers a free-form send with 131047 (window closed), the template goes out instead. Outcomes and events have no template: inside the window they are free-form messages, outside it they are not sent over WhatsApp at all (see What costs money below).
  • Deciding: buttons, list rows and quick replies carry a:<askId>:<option index>; they count only from the connected number, for an Owner or Decider of the ask's workspace. Asks that allow text say so; a reply (swipe to reply) to the ask's message decides it in words.
  • Outcome: WhatsApp can't edit Logu's messages, so the outcome is a reply to the ask's message ("Ana decided: Approve"), only inside the window; outside it nothing is sent (the ask is in the app, and push and the other deliveries clear their own notification). The member who decided in WhatsApp already got "Decided: …" and isn't told twice. Being a new message, it follows quiet hours and mutes like an event.
  • Delivery runs in the push queue consumer: 429, 5xx and Meta's throttling codes retry the job; other errors are logged (status and code only) and dropped. Images follow an ask or event inside the window only. Templates are billed by Meta per delivered message (utility rate); free-form messages inside the window are free.

Setup (once; Logu never creates these for you):

  1. In Meta Business: a business portfolio (verified, to message beyond test numbers), a WhatsApp Business Account and a phone number registered for the Cloud API (display name approved).
  2. In Meta for Developers: an app of type Business with the WhatsApp product, linked to that WhatsApp Business Account. Note the App ID and the App secret (App settings → Basic).
  3. In Business settings → Users → System users: a system user with a permanent token for the app, with whatsapp_business_messaging and whatsapp_business_management, and the WhatsApp Business Account assigned to it.
  4. Run CLOUDFLARE_ACCOUNT_ID=… pnpm whatsapp:setup [api-url] [app-url]. It asks for the token, the app secret, the App ID, the WhatsApp Business Account ID and the phone number ID (secrets not echoed), checks them with the Graph API, stores WHATSAPP_TOKEN, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_NUMBER, WHATSAPP_APP_SECRET and a generated WHATSAPP_VERIFY_TOKEN on logu-api, submits the two templates (with the app's URL in their Open in Logu button), subscribes the app to the WhatsApp Business Account and points the app's webhook (messages field) at <api-url>/whatsapp/webhook. Safe to re-run: templates that exist are left alone. An account set up before outcomes and events lost their templates may still hold logu_event and logu_outcome: the script says they are unused, and they can be deleted in WhatsApp Manager (Logu never sends them; leaving them costs nothing).
  5. Wait for Meta to approve the templates (WhatsApp Manager → Message templates; usually minutes). Until then, deliveries outside the window fail with a template error and are dropped.
  6. In the app: Settings → Delivery → Connect.

What costs money. Meta bills each template message that is delivered, and nothing inside the 24-hour window. Per person, an ask outside the window is two templates at most (the ask, and its reminder if it is still waiting); it used to be three, because the outcome was also a template, and one more for every event. The outcome and events are now window-only: a person who does not write to Logu for a day sees them in the app, on push and on their other deliveries, and the WhatsApp one is skipped without cost or retry. To keep them on WhatsApp the person only has to send Logu any message (or tap a button) now and then, which opens the window for 24 hours. That is a third off the WhatsApp messages of every ask, and all of the event ones.

Cap. Meta bills each template message; a message inside the 24-hour window is free. Template messages are capped per workspace (docs/billing.md): 500 a month as a cost ceiling that is always on, and when plan limits are enforced Free has none and Pro has 100. Past the cap the WhatsApp send is skipped and the ask is never blocked: it is in the app and on every other delivery. Owners see a note on Settings → Plan, and the activity log records delivery.capped once a month.

Reminders#

The ask's Workflow sleeps until the reminder time (wait-before-reminder), then, if the ask is still waiting, reminds its recipients (remind; every Owner and Decider unless the project routes them, plus whoever escalation added): a push job per member (phones, browsers Telegram, Slack and WhatsApp) and an email to those who turned email on, each held during the member's quiet hours like the original. Then it keeps waiting for the human until the timeout. Asks shorter than 10 minutes get no reminder. When the ask settles, Logu sends an ask-settled push that replaces the original notification on every device (or removes it, for the member who decided), so a decided ask doesn't linger on the lock screen of the other phone. Formats: push.md.

/r/<token> decides one ask with one option. The token is base64url({a: askId, o: optionId, e: expiresAt, u: userId}) + "." + HMAC-SHA256, keyed by a value derived from BETTER_AUTH_SECRET (no extra secret to manage). Each recipient member gets links signed for them, so a decision made through a link records who decided. Links sent before workspaces (no u) decide as the personal workspace's owner. Rules:

  • GET never decides. It shows a confirmation page with a single button; the button POSTs. Email scanners and link previews follow GET, so they can't approve anything.
  • A link decides only its ask, only with its option, and stops working once the ask is settled (decided, withdrawn or timed out: 409) or the token is tampered with or past its expiry (400).
  • Holding the link is the authority to decide, so links only go to that member's own inbox. A link stops working (403) once its member is no longer an Owner or Decider in the workspace.

How long notifications point somewhere#

Every delivery links to the ask or event in the app (an app link). Logu keeps settled asks and events for 180 days (retention, LIMITS.retentionDays): after that the ask or event is deleted with its images, and an old email, Telegram, Slack or WhatsApp message or notification opens a "not found" page. Telegram, Slack and WhatsApp messages kept for an ask (to show their outcome) go with it. Asks still waiting are never deleted, so a link that can still decide always finds its ask.

Invitations#

Owners invite by email with a role (POST /v1/workspaces/:id/invitations). The email (from EMAIL_FROM, decide@logu.dev in production) carries a one-time link to APP_URL/invite?token=…, valid 7 days; only the token's SHA-256 is stored, and the Owner also gets the link in the response to share it another way when email is off. Accepting (POST /v1/invitations/accept) requires signing in with the invited email. One pending invitation per email per workspace; resending issues a new link and the old one stops working.

Email (Cloudflare Email Service)#

Email is off until both of these exist:

  1. A sending domain onboarded to Cloudflare Email Service, and a send_email binding named EMAIL in apps/api/wrangler.jsonc:

    "send_email": [{ "name": "EMAIL" }]
  2. EMAIL_FROM set to an address on that domain (a vars entry, e.g. logu@yourdomain.com), and APP_URL pointing at the deployed inbox so emails can link back to it.

Emails are capped (docs/billing.md): 5,000 a month per workspace and 100 a day per member, as cost ceilings that are always on. Past a cap the email is skipped (the ask is never blocked, and an invitation still returns its link to share by hand); Owners see a note on Settings → Plan and the activity log records delivery.capped once a month.

Production (logu.dev) sends from decide@logu.dev. Setup, once:

cd apps/api
wrangler email sending enable logu.dev            # SPF/DKIM/DMARC for sending
wrangler email routing enable logu.dev            # MX records, so replies can arrive
wrangler email routing rules create logu.dev --name decide --match-type literal \
  --match-field to --match-value decide@logu.dev --action-type forward --action-value <owner inbox>

Replies to Logu emails go to decide@logu.dev and are forwarded to the owner's inbox (the destination address must be verified in Email Routing first; it's per account).

GET /v1/preferences reports emailAvailable, and the Settings screen tells the human when the server can't send email yet. Sending is best effort: a failed email is logged, and the inbox and push still carry the ask.