LoguDocs
Open Logu

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#

  1. Upload: POST /v1/attachments with the file as the body and its Content-Type (?name=after.png for its file name, the image's alt text), or multipart/form-data with a file field. Returns { id: "att_…", url, contentType, size, width, height, name, expiresAt }.
  2. Attach: list the ids in attachments on POST /v1/asks or POST /v1/events, in the order to show them.
  3. 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 attachments ids, 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: images are 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 same idempotency_key and 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-Length is 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_reached or plan_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)>, the url of every attachment. The key is derived from BETTER_AUTH_SECRET (label logu-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 allows img-src from the API.

Delivery#

  • Push (Android): FCM notification.image, shown by Android itself.
  • Push (iOS): mutable-content: 1 and fcm_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) or sendMediaGroup (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.