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 |
| 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) |
| 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.
quietHoursIgnoredsays 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/testwith{ delivery: "push" | "browser" | "email" | "telegram" | "slack" | "whatsapp" }answers{ delivery, result, count, quietHoursIgnored };resultissent,not_connected(nothing is set up for it),unavailable(this server doesn't send it) orfailed. Humans only (an API key gets 403), at most 5 a minute per member (429 withRetry-After). It is recorded asdelivery.testedin the activity log (data: the delivery, nothing else). Code:apps/api/src/push/test.ts, the push job typetest.
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):notifylists 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.createdrecordsdata.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,
escalationin the samePUT): if an ask is still waitingafterMinutesafter 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 runsescalate: 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), recordsask.escalatedand setsAsk.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 throughsettleAskas soon as it's created:decidedVia: "rule",decidedBythe Owner who set the rule,ruleId; the activity log hasask.decidedby that Owner withdata.via: "rule". Nobody is notified, and the agent gets the decided ask in thePOST /v1/asksresponse (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) andlastAt. 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 throughPOST /v1/rulesfor 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/preferenceswith{ quietHours: { start, end, timeZone } | null, quietAsks }, andPATCH /v1/projects/:idwith{ muted }. Only humans can change them. The policy lives inapps/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 withlogu install claude-codeorlogu install codex(the device flow withclient_kindclaude-codeorcodex) starts atasks_only, because coding agents report often and the asks are what need you. Every other agent starts atasks_and_events.asks_onlyis what a coding agent starts with;silentis 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 belowasks_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/:idwith{ notificationLevel }(Owners and Deciders; recorded asagent.updatedin the activity log);notificationLevelis on everyAgent. - The iOS lock screen groups an agent's notifications into one stack: Logu sets the APNs
thread-idto 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./stopdisconnects. 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):
- Create a bot with @BotFather (
/newbot) and copy its token. - Run
CLOUDFLARE_ACCOUNT_ID=… pnpm telegram:setup. It asks for the token (not echoed), storesTELEGRAM_BOT_TOKEN, a generatedTELEGRAM_WEBHOOK_SECRETandTELEGRAM_BOT_USERNAMEon logu-api, and points the bot's webhook at the API. - 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:writeandim:write(nothing that reads messages) and a one-timestate(valid 10 minutes, stored as a SHA-256). Slack sends the member back toGET /slack/oauth, which exchanges the code, opens the DM with the installing Slack user and connects it; the app pollsGET /v1/slackmeanwhile. Org-wide (Enterprise Grid) installs are refused. Bot tokens are stored encrypted (AES-GCM, key derived fromBETTER_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.decidedViaisslack. - 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/interactionsandPOST /slack/eventscheck Slack's signature (v0=HMAC-SHA256 ofv0:<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 (andratelimited) retry the job; a revoked token or an unreachable DM disconnects. - Without the three secrets below,
GET /v1/slacksaysavailable: falseand Settings says Slack isn't set up on this server.
Setup (once per server):
- 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. - In the Slack app's settings, turn on public distribution (Manage Distribution).
- Store
SLACK_CLIENT_ID,SLACK_CLIENT_SECRETandSLACK_SIGNING_SECRET(Basic Information → App Credentials) on logu-api:CLOUDFLARE_ACCOUNT_ID=… pnpm slack:setup [api-url] --storeasks for them without echoing, orwrangler secret puteach. - 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, elselogu_askwith Show options (tapping it opens the window and Logu answers with the interactive message) and Open in Logu. Each is inenandpt_BRand ends with Open in Logu. Definitions:apps/api/whatsapp-templates.json. If Meta answers a free-form send with131047(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):
- 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).
- 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).
- In Business settings → Users → System users: a system user with a permanent token for the
app, with
whatsapp_business_messagingandwhatsapp_business_management, and the WhatsApp Business Account assigned to it. - 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, storesWHATSAPP_TOKEN,WHATSAPP_PHONE_NUMBER_ID,WHATSAPP_NUMBER,WHATSAPP_APP_SECRETand a generatedWHATSAPP_VERIFY_TOKENon 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 (messagesfield) 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 holdlogu_eventandlogu_outcome: the script says they are unused, and they can be deleted in WhatsApp Manager (Logu never sends them; leaving them costs nothing). - 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.
- 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.
Signed decision links#
/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:
GETnever decides. It shows a confirmation page with a single button; the buttonPOSTs. Email scanners and link previews followGET, 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:
A sending domain onboarded to Cloudflare Email Service, and a
send_emailbinding namedEMAILinapps/api/wrangler.jsonc:"send_email": [{ "name": "EMAIL" }]EMAIL_FROMset to an address on that domain (avarsentry, e.g.logu@yourdomain.com), andAPP_URLpointing 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.