Attachments (images on asks and events)
Agents can show the human images with an ask or an event: up to 4, each a PNG, JPEG, WebP or GIF of up to 5 MB. The typical use is a UI pull request's before/after screenshots inside the ask itself. The app shows them on the ask or event (a strip with a lightbox) and as a thumbnail in the list; notifications show the first one.
Flow#
- Upload:
POST /v1/attachmentswith the file as the body and itsContent-Type(?name=after.pngfor its file name, the image's alt text), ormultipart/form-datawith afilefield. Returns{ id: "att_…", url, contentType, size, width, height, name, expiresAt }. - Attach: list the ids in
attachmentsonPOST /v1/asksorPOST /v1/events, in the order to show them. - Show: every ask and event has
attachments: [{ id, url, contentType, size, width, height, name }].
Clients: CLI logu ask "Ship it?" --image before.png --image after.png; TypeScript
logu.uploadAttachment("after.png"); Python logu.upload_attachment("after.png"); MCP
ask_human / send_event with images: [{ data_base64, content_type, name }] (inline, for small
images; through logu mcp an image can be a local path) or attachments: ["att_…"].
Why upload first, not base64 in the ask#
- The JSON bodies of asks and events stay small: no 33 % base64 overhead in every request, retry and idempotency record (the API stores the first response of an idempotent create).
- Idempotency is unchanged: a retry sends the same
attachmentsids, so it hashes the same and replays the first ask. The images upload once, before the create, and a failed create leaves them unused (cleaned up after 24 h) instead of half-created. - MCP is the exception, because a tool call can't upload first:
imagesare inline base64. They are stored only when the ask or event is actually created, and the idempotency fingerprint uses their SHA-256s, so a retry with the sameidempotency_keyand the same images replays the first ask without storing them again.
Rules#
- Types are recognized by the file's first bytes (PNG, JPEG, GIF, WebP signatures), never by
the declared
Content-Type, which may be anything (curl sends form-urlencoded by default). A declared image type Logu doesn't take (image/svg+xml,image/heic) is refused by name. SVG is never accepted: it can carry scripts. - Size: 5 MB each (
Content-Lengthis checked first; a body without one is read up to the limit and no further). 4 per ask or event. - Rate limit: 120 images per minute per API key (
ATTACHMENTS_RATE_LIMITER), on top of the 1200 requests; each inline MCP image counts too. - Limits per workspace (docs/billing.md): 20,000 uploads a month and 25 GB held at once as a
cost ceiling that is always on; the plan's storage quota (Free 200 MB, Pro 20 GB) when limits are
enforced. An upload past one is refused (402
ceiling_reachedorplan_limit) before anything is stored; sending the ask or event without the image works. Unused uploads count until they're deleted. - Ownership: an upload can be attached only by the agent that uploaded it, in its workspace, once (it belongs to that one ask or event), within 24 hours.
- Cleanup: the daily cron (
src/maintenance.ts) deletes uploads no ask or event used within 24 hours, from R2 and D1. Deleting a workspace deletes its images (everything under its R2 prefix; the rows go with the workspace). An ask whose workflow failed to start is removed; its images become unused again (a retry can attach them) and are cleaned up if it doesn't. - Retention: an ask's or event's images are deleted with it, 180 days after the ask settled
or the event was sent (
LIMITS.retentionDays,src/services/retention.ts): R2 bytes first, then the rows. Asks still waiting are never deleted. The daily run deletes in batches of 50 (up to 1,000 asks and 1,000 events a run, a few subrequests per batch), so a backlog finishes over the following days instead of hitting the Worker's limits.
Storage#
R2 bucket logu-attachments (binding ATTACHMENTS), private (no public bucket URL or custom
domain), keys <workspace id>/<attachment id>. D1 table attachment (migration
0015_attachments.sql): id, workspace, uploading agent, R2 key, the content type found in the
bytes, size, width and height (read from the header when cheap), file name, SHA-256, the ask or
event and its position. workspace_storage keeps each workspace's bytes held: an upload adds, the
unused-upload cleanup and retention subtract, and the daily maintenance recomputes it from the
attachment rows (reconcileStorage), so a lost update never lasts a day.
Serving#
GET /v1/attachments/:id: members of the workspace (session) and the agent that uploaded it.- Signed links:
<API>/a/:id?exp=<unix seconds>&sig=<HMAC-SHA256(id.exp)>, theurlof every attachment. The key is derived fromBETTER_AUTH_SECRET(labellogu-attachment-urls), so there's no new secret; rotating that secret invalidates every link (and sessions anyway). A link expires at the end of the next whole hour (1 to 2 hours), so it stays the same within an hour and browsers cache the image. The app uses them for<img>(it authenticates with a bearer token, which an<img>can't send) and fetches the ask again when one has expired. - Every image response:
Content-Disposition: inline,X-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; sandbox,Cache-Control: private,Cross-Origin-Resource-Policy: cross-origin(the app is on another origin),Referrer-Policy: no-referrer. The app's CSP allowsimg-srcfrom the API.
Delivery#
- Push (Android): FCM
notification.image, shown by Android itself. - Push (iOS):
mutable-content: 1andfcm_options.image; the app's Notification Service Extension (apps/web/ios/App/NotificationService) downloads the image and attaches it (docs/push.md). - Browser notifications: the payload's
image(Chrome shows it; others ignore it). - Telegram: after the ask's or event's message, as a reply to it,
sendPhoto(one) orsendMediaGroup(2–4) with the signed links (Telegram fetches them). Sent after, not before: the message with its buttons must never wait on, or be resent because of, an image. A failure is logged and not retried. Reminders don't resend them. - Slack: after the ask's or event's message, image blocks in its thread (Slack fetches the signed links). Best effort: a failure is logged and not retried, and reminders don't resend them.
- WhatsApp: after the message, one image per signed link as a reply to it, only inside the 24-hour window (templates carry no image; delivery.md).
- Email: a line "2 images: see them in Logu" linking to the ask in the app, no image links. Signed links expire within 2 hours and emails are read much later; long-lived links in a mailbox would be a standing way into private images. The app is where they're seen.
Signed links in notifications expire like any other: a notification delivered hours late (quiet hours, a phone that was off) shows no image, and the app still does.
Storage and retention#
Images live at most as long as their ask or event, 180 days after it settled or was sent, so storage stops growing after about six months. Each workspace also has an image storage ceiling and a plan quota (docs/billing.md); past either, an upload answers 402 with a hint.