Time Blocking
See available time and existing tasks, then build the day by placing blocks. The core “plan my day” surface.
GET
/timeblocking/days
timeblocking:read
List days in a window, each with its blocks + totals.
- from
- date, optional — default yesterday
- to
- date, optional — default +14 days
- person_id
- uuid, optional — view a teammate (read-only)
Example response
{ "days": [ { "date": "2026-06-28", "wake": "07:00", "sleep": "01:00", "blocks": [ { "id": "uuid", "title": "Recov ads", "client": "RecovFaster", "project": "June Ads", "start": "09:00", "end": "09:30", "duration_min": 30, "status": "not_started", "locked": false, "actual_minutes": null } ], "totals": { "blocked_min": 30, "done_min": 0 } } ] }
GET
/timeblocking/day
timeblocking:read
One day's blocks and free gaps, plus the day's wake/sleep bounds. Each block carries "locked" and "recurring" flags — locked blocks cannot be moved/edited via the API (see Rules); free gaps are bounded by wake→sleep.
- date
- YYYY-MM-DD, required
- person_id
- uuid, optional
Example response
{ "date": "2026-06-28", "wake": "06:30", "sleep": "23:00", "blocks": [ { "id": "uuid", "title": "Holly call", "client": "Hair Test Lab", "start": "13:30", "end": "14:00", "status": "not_started", "locked": true, "recurring": false } ], "free": [ { "start": "10:00", "end": "12:30", "minutes": 150 } ] }
GET
/timeblocking/availability
timeblocking:read
Free slots for the day (working windows minus blocks), bounded by wake→sleep. When bedtime is after midnight the day extends past 24:00 — those slots carry "starts_next_day"/"ends_next_day": true and their times are next-morning clock times.
- date
- YYYY-MM-DD, required
Example response
{ "date": "2026-06-28", "wake": "07:00", "sleep": "01:00", "slots": [ { "start": "09:00", "end": "10:30", "minutes": 90 }, { "start": "23:00", "end": "01:00", "minutes": 120, "ends_next_day": true } ] }
GET
/timeblocking/tasks
timeblocking:read
Open tasks to schedule — title, client, project, estimate, due/overdue.
- scheduled
- true|false, optional — default false
Example response
{ "tasks": [ { "id": "uuid", "title": "Edit reel", "client": "Gala Equine", "project": "July Retainer", "estimate_min": 30, "due": "2026-06-29", "status": "not_started", "locked": false } ] }
POST
/timeblocking/blocks
timeblocking:write
Place one block (own grid only). Pass task_id to schedule an existing task, or title to create one. Creating by title REQUIRES work_type — deliverable | communication | sales | revisions | admin on a real client, building | content | internal_admin on our own brands or with no client, and none at all on a personal block. status defaults to not_started — pass "status":"completed" to LOG work you have already done. Tag a personal-life block with "client_id":"personal" (or "personal":true).
Request body
{ "task_id"?: "uuid", "title"?: "string", "work_type"?: "required with title unless personal", "client_id"?: "uuid | \"personal\"", "personal"?: true, "project_id"?: "uuid", "work_date": "YYYY-MM-DD", "start_time": "HH:MM", "end_time": "HH:MM", "status"?: "not_started | next_up | working_on_it | completed", "actual_minutes"?: 30, "locked"?: true }
Example response
{ "block": { "id": "uuid", "title": "Edit reel", "client": "Gala Equine", "start": "09:00", "end": "09:30", "status": "completed", "locked": false } }
POST
/timeblocking/plan
timeblocking:write
Lay a list of tasks into the day’s free slots in order, respecting estimates. Returns the resulting schedule. Own grid only.
Request body
{ "date": "YYYY-MM-DD", "task_ids": ["uuid", ...] }
Example response
{ "scheduled": [ { "task_id": "uuid", "start": "09:00", "end": "09:30" } ], "unplaced": [] }
PATCH
/timeblocking/blocks/{id}
timeblocking:write
Update a block (own grid only): move/resize, rename (title), set its status (e.g. mark it completed), re-tag its client / project / personal, change its work_type, or lock/unlock it. Send only the fields you want to change. Re-tagging to a different KIND of client (a real client vs one of our own brands) is refused unless a valid work_type comes with it — the 5 client kinds and the 3 internal ones are not interchangeable. Making a block personal clears its work_type. IMPORTANT: a LOCKED block refuses every change with HTTP 423 EXCEPT { "locked": false } (unlock). To edit a locked block, unlock it first (confirm with the user), then send your change in a second call.
Request body
{ "work_date"?: "YYYY-MM-DD", "start_time"?: "HH:MM", "end_time"?: "HH:MM", "title"?: "string", "status"?: "not_started | next_up | working_on_it | completed", "client_id"?: "uuid | \"personal\"", "personal"?: true, "project_id"?: "uuid | null", "work_type"?: "deliverable | communication | sales | revisions | admin | building | content | internal_admin", "actual_minutes"?: 30, "locked"?: false }
DELETE
/timeblocking/blocks/{id}
timeblocking:write
Remove a block (own grid only).
Tasks + Agent Work
The backlog, and how an agent works a task and hands the work back INTO it. A task holds any number of runs; each run has a live event log (progress lines, replies, errors, deliverables), files land on the task as attachments, and finished work lands as RESOURCES — a self-contained HTML artifact the task renders, a PDF, a written recap or a link. The person watches it arrive in the task’s Agent panel. Reads are global; every write is on the key owner’s OWN tasks only (403 otherwise — assign the task to them in the app first).
GET
/tasks
tasks:read
The backlog. Default: the key owner’s OPEN, top-level, non-recurring, non-personal tasks, sorted by due date with undated last. Each carries client, project, due, estimate_min, locked, mine and agent_status (the latest run’s status, or null). total_estimate_min sums the list.
- status
- 'open' (default) | 'completed' | 'all' | one status
- assignee
- 'me' (default) | 'all' | a person uuid
- client_id
- uuid, or 'personal' for personal tasks
- project_id
- uuid
- q
- text — title search
- include_sub_items
- 'true' to include sub-items
- include_recurring
- 'true' to include repeating occurrences
- include_personal
- 'true' to include personal tasks
- limit
- number, default 200, max 500
Example response
{ "count": 2, "total_estimate_min": 105, "tasks": [ { "id": "uuid", "title": "Build the NSI landed-cost sheet", "status": "not_started", "work_type": "deliverable", "client": "NSI Nails", "project": "September retainer", "assignee": "Zac", "mine": true, "locked": false, "estimate_min": 60, "due": "2026-09-15", "agent_status": null } ] }
POST
/tasks
tasks:write
Create a task on the key owner’s backlog (201). This is how work that matches no open task gets its task: search GET /tasks?q= first (match on title, client and deliverable, not exact words) and create only when nothing fits. Needs a title and a work_type that fits the client kind (a real client: deliverable | communication | sales | revisions | admin; our own brands or no client: building | content | internal_admin; personal tasks take none). "schedule": "now" also places it on the grid at the current Brisbane time, start snapped down to 5 minutes, for estimate_min (30 when none is sent, and the response carries a note asking you to PATCH the real estimate once the person says how long). The span is checked for overlaps like any block; when it is taken the task is still created, untimed, with the conflicts listed ("on_conflict": "refuse" returns 409 instead). "due" is a day with no time. The owner’s assignment row is written too, so the task shows on the Board as well as the grid, and the response hint says how to start the run.
Request body
{ "title": "Kez Rugs: homepage hero swap", "client_id": "uuid", "work_type": "deliverable", "estimate_min": 45, "description"?: "what the person asked for, in one paragraph", "project_id"?: "uuid", "status"?: "working_on_it", "due"?: "2026-09-16", "schedule"?: "now" | { "work_date": "2026-09-14", "start_time": "14:00", "end_time": "14:45" }, "on_conflict"?: "backlog" }
Example response
{ "task": { "id": "uuid", "title": "Kez Rugs: homepage hero swap", "status": "working_on_it", "work_type": "deliverable", "client": "Kez Rugs", "assignee": "Zac", "mine": true, "estimate_min": 45, "due": "2026-09-14", "start": "14:00", "end": "14:45" }, "placed": { "work_date": "2026-09-14", "start": "14:00", "end": "14:45", "minutes": 45 }, "hint": "Now work it inside a run: POST /tasks/{id}/agent/runs …" }
GET
/tasks/{id}
tasks:read
Everything needed to do the work: description, links, sub_items, parent, attachments (a person’s briefs and past deliverables, each with a url you can fetch) and every agent run with its full event log — read past runs before starting so you do not redo finished work.
Example response
{ "task": { "id": "uuid", "title": "…", "description": "…", "attachments": [ { "id": "uuid", "file_name": "brief.pdf", "url": "https://…", "mime_type": "application/pdf", "source": "user" } ], "sub_items": [], "agent_runs": [ { "id": "uuid", "status": "done", "title": "…", "summary": "…", "events": [ { "kind": "log", "body": "…" } ] } ] } }
PATCH
/tasks/{id}
tasks:write
Update your own task: status (any task status — not_started, next_up, working_on_it, waiting_on with a waiting_reason, asap, ideas, completed; aliases like done / in_progress work), title, description, actual_minutes. Completing stamps completed_at and, when no time was logged, records actual_minutes from the block span or the estimate so the task never lands in Data Cleanup; a parent is REFUSED with 409 while any sub-item is open. A locked task refuses status/title with 423. Only tick a task off when the person asked for that; finishing a run does not complete the task by itself. estimate_min updates the estimate everywhere the app reads it (the column and the owner’s assignment row) — the answer to "how long will it take" goes here; it does not resize a timed block (PATCH /timeblocking/blocks/{id} {end_time} does that). due moves an untimed task’s day; a timed block is 409 here and is moved through /timeblocking/blocks so the overlap check runs.
Request body
{ "status"?: "completed", "waiting_reason"?: "with status waiting_on: who or what it is waiting on", "title"?: "string", "description"?: "string", "estimate_min"?: 60, "due"?: "2026-09-16", "actual_minutes"?: 45 }
GET
/tasks/{id}/attachments
tasks:read
Files on the task. source "user" = dropped in the app (briefs, references); "agent" = deliverables. Each has url, mime_type, file_size, run_id, caption.
POST
/tasks/{id}/attachments
tasks:write
File a deliverable ON the task — a PDF for anything detailed, an image, any file (≤10MB, base64). Pass run_id so it shows inside that run’s log as a deliverable at the moment it landed; the run must still be running (a closed run is 409 — omit run_id or start a new run). mime_type is sniffed from the filename when omitted. Verify by byte size: file_size in the response must equal the bytes you sent. local_path is REQUIRED (400 without it): the absolute path of the file on the machine you built it on — the task keeps it, shows it, and its Reveal in Finder opens that local copy later, so file from where the file will stay (the client or project folder), never from a temp dir.
Request body
{ "filename": "nsi-landed-cost.pdf", "content_base64": "<base64 of the file bytes>", "local_path": "/Users/…/clients/nsi nails/reports/nsi-landed-cost.pdf", "mime_type"?: "application/pdf", "caption"?: "Landed cost per SKU, Sept 2026", "run_id"?: "uuid" }
Example response
{ "attachment": { "id": "uuid", "file_name": "nsi-landed-cost.pdf", "url": "https://…", "mime_type": "application/pdf", "file_size": 48213, "local_path": "/Users/…/clients/nsi nails/reports/nsi-landed-cost.pdf", "source": "agent", "run_id": "uuid" } }
DELETE
/tasks/attachments/{id}
tasks:write
Remove a file the agent uploaded (storage + row). A person’s own upload is refused with 409 — that is removed in the app.
GET
/tasks/{id}/resources
tasks:read
Artifacts on the task, newest first. Bodies come back as a 400-character preview so reading a task never drags megabytes of markup with it; pass include_body=true for the full ones, or read a single resource below.
- include_body
- 'true' to return full bodies instead of previews
Example response
{ "count": 1, "resources": [ { "id": "uuid", "kind": "html", "title": "Lisa — events page email", "byte_size": 4967, "mime_type": "text/html", "source": "agent", "run_id": "uuid", "body_preview": "<table role=\"presentation\" style=…", "body_truncated": true } ] }
POST
/tasks/{id}/resources
tasks:write
Hand back an artifact the task RENDERS. kind "html" is the main one: send the whole self-contained page in `html` (≤2MB) and it shows in the task as a card with Preview, Open in new tab (a sandboxed page, never a download), Reveal in Finder, Copy HTML and Download — that is how a client email draft comes back ready to send. "text" is a written recap. "pdf" / "image" surface a file already POSTed to /tasks/{id}/attachments (pass its attachment_id). "link" is a url. kind is inferred from what you send when you leave it out. Pass run_id so it lands inside that run’s log at the moment you made it (a closed run is 409). local_path is REQUIRED for every kind but "link" (400 without it): the absolute path of the source file you filed it from, the .html or .md you wrote; a "pdf"/"image" resource inherits its attachment’s. Reveal in Finder in the task opens it, so leave the file there. The HTML is stored as text and only ever rendered in a sandboxed iframe with no scripts and a strict CSP, so keep it self-contained: inline styles, absolute https images, no external JS or CSS.
Request body
{ "kind": "html", "title": "Lisa — events page email", "caption": "Ready to send, asks for her event list and photos", "html": "<!doctype html>…", "local_path": "/Users/…/clients/cavalle/client emails/draft/lisa-events-email.html", "run_id": "uuid" }
Example response
{ "resource": { "id": "uuid", "task_id": "uuid", "kind": "html", "title": "Lisa — events page email", "byte_size": 4967, "mime_type": "text/html", "source": "agent", "run_id": "uuid" } }
GET
/tasks/resources/{id}
tasks:read
One resource WITH its full body — how you read an HTML artifact back out, e.g. to revise a draft you filed earlier.
PATCH
/tasks/resources/{id}
tasks:write
Revise an artifact in place: title, caption, body, url or local_path. A resource filed under a CLOSED run is FINAL (409) — its run’s record of what was delivered stands, so post the revised version in a new run. A person’s own resource is 409 too.
Request body
{ "title": "New name", "html": "<!doctype html>…" }
DELETE
/tasks/resources/{id}
tasks:write
Remove an agent’s resource. A person’s own resource is refused with 409 — that is removed in the app.
GET
/tasks/{id}/agent/runs
tasks:read
The task’s agent runs, newest first, each with its events.
POST
/tasks/{id}/agent/runs
tasks:write
Start a run on your task (status "running"). Give it a one-line title saying what this run will do; model is shown in the panel. 409 while another run is still in progress unless force:true. The response hint lists the three follow-up calls.
Request body
{ "title": "Build the landed-cost sheet from the supplier invoice", "model": "claude-opus-5", "agent"?: "claude-code", "brief"?: "the instruction you were given", "force"?: false }
Example response
{ "run": { "id": "uuid", "task_id": "uuid", "status": "running", "started_at": "2026-09-13T04:10:00Z" }, "hint": "…" }
GET
/tasks/agent/runs/{runId}
tasks:read
One run with its full event log.
POST
/tasks/agent/runs/{runId}/events
tasks:write
Append to the live log — one event or a batch. "log" = a progress line (what you are reading, deciding, building; keep them short and frequent so the person can follow). "reply" = a written answer for the person (plain text; bare URLs become links). "error" = what stopped you. Only accepted while the run is running.
Request body
{ "kind": "log", "body": "Read the supplier invoice: 42 lines, 3 SKUs missing cost" } or { "events": [ { "kind": "log", "body": "…" }, { "kind": "reply", "body": "…" } ] }
Example response
{ "events": [ { "id": "uuid", "kind": "log", "body": "…", "created_at": "…" } ] }
PATCH
/tasks/agent/runs/{runId}
tasks:write
Close the run: status done | failed | cancelled (or back to running). summary is the closing reply — 2-3 lines on what was delivered and where — and is also posted as a reply event. finished_at is stamped and the task’s agent_status follows its latest run. A closed run is FINAL: the only change it accepts afterwards is { "status": "running" } to reopen it (anything else is 409) — more work goes in a new run.
Request body
{ "status": "done", "summary": "Landed-cost sheet attached (PDF). 3 SKUs had no supplier cost; flagged in red on page 2." }
Notes (Keep)
Durable context saved against a client — the same notes the app’s Keep page shows, so a note written here appears there at once. Use it for what the next person or agent working on the client needs to know: a decision, a preference, a standing fact. It is not a log: what happened on a job goes on its task. Keep is an owner-only page, so this is the one section where reads are NOT global: an admin’s key reads every note, any other key reads only the notes it wrote. Writes are author-only — you can change or delete only the notes your key’s owner wrote (403 otherwise). A note can be archived instead of deleted: PATCH {"archived":true} puts it away without losing it, and GET /notes shows live notes only by default.
GET
/notes
notes:read
Notes, newest first (created_at). Each row carries client, project, pinned, archived, color, created_by_name, images (pictures pasted into the note in the app, each {url, thumb_url, width, height, mime_type, size, name}; the files are private, so url and thumb_url are signed links good for ONE HOUR: read the note again for fresh ones) and a 400-character body_preview + body_truncated, never the whole text — read one in full with GET /notes/{id}, or pass include_body=true. Defaults to LIVE notes only: an archived note has been put away, so it needs archived=true or archived=any to show up here. "visibility" says what you were shown: "all" for an admin’s key, "own" for any other key (only the notes it wrote).
- client_id
- uuid, optional — or 'personal' for notes with no client
- project_id
- uuid, optional
- q
- text, optional — matches title or body, case-insensitive
- archived
- 'false' (default, or omit) = live only, 'true' = archived only, 'any' = both
- limit
- number, default 50, max 200
- include_body
- 'true' to return full bodies instead of previews
Example response
{ "count": 1, "visibility": "all", "notes": [ { "id": "uuid", "title": "Invoicing: always to accounts@", "body_preview": "Lisa wants every invoice sent to accounts@ with the PO number in the subject line.", "body_truncated": false, "pinned": true, "archived": false, "color": "#d97706", "images": [ { "url": "https://…/object/sign/note-images/…", "thumb_url": "https://…", "width": 1512, "height": 982, "mime_type": "image/png", "size": 412331, "name": null } ], "client_id": "uuid", "client": "Cavalle & Co", "project_id": null, "project": null, "created_by": "uuid", "created_by_name": "Zac", "created_at": "2026-09-17T03:20:00Z", "updated_at": "2026-09-17T03:20:00Z", "app_url": "https://makeithappen-one.vercel.app/#keep" } ] }
GET
/notes/{id}
notes:read
One note with its full body, exactly as it was stored, and its images as one-hour signed links. 403 when the note is not yours and your key’s owner is not an admin.
Example response
{ "note": { "id": "uuid", "title": "Invoicing: always to accounts@", "body": "Lisa wants every invoice sent to accounts@ with the PO number in the subject line.\n\nAgreed on the 17 Sep call.", "pinned": true, "archived": false, "color": "#d97706", "client_id": "uuid", "client": "Cavalle & Co", "project_id": null, "project": null, "created_by": "uuid", "created_by_name": "Zac", "created_at": "2026-09-17T03:20:00Z", "updated_at": "2026-09-17T03:20:00Z", "app_url": "https://makeithappen-one.vercel.app/#keep" } }
POST
/notes
notes:write
Save a note against a client (201). It shows in the app’s Keep page at once, under that client, written by the key owner. For durable context only — a decision, a preference, a standing fact — never a task log. "body" is plain text or markdown and is stored EXACTLY as sent: no trimming, no HTML rewriting. Max 100,000 bytes of UTF-8 (413 above it, with a hint); "title" max 300 characters (400 above it); a note needs a title or a body. Give client_id, project_id or both (at least one, 400 otherwise): with only project_id the client is taken from the project so the note shows under that client; with both, the project must belong to that client (400). An unknown client or project is 404. "color" is a palette id (red | amber | green | teal | blue | purple, stored as its hex), a #rrggbb hex, or null. Images are not set through the API: they are pasted or dropped into the note in Keep. Verify a long note by reading it back with GET /notes/{id} and comparing the byte length.
Request body
{ "client_id": "uuid", "title"?: "Invoicing: always to accounts@", "body": "Lisa wants every invoice sent to accounts@ with the PO number in the subject line.\n\nAgreed on the 17 Sep call.", "project_id"?: "uuid", "pinned"?: true, "color"?: "amber" }
Example response
{ "note": { "id": "uuid", "title": "Invoicing: always to accounts@", "body": "Lisa wants every invoice sent to accounts@ …", "pinned": true, "archived": false, "color": "#d97706", "client_id": "uuid", "client": "Cavalle & Co", "project_id": null, "project": null, "created_by": "uuid", "created_by_name": "Zac", "created_at": "2026-09-17T03:20:00Z", "updated_at": "2026-09-17T03:20:00Z", "app_url": "https://makeithappen-one.vercel.app/#keep" } }
PATCH
/notes/{id}
notes:write
Edit a note YOU wrote — 403 on anyone else’s, and on a note with no recorded author (that one is edited in the app). Send only the fields to change. A real edit stamps updated_at; pinning and archiving alone do not, because neither is an edit (same rule as the app, so a pin or an archive never changes a note’s "edited" date). "archived": true/false archives or restores the note (same shelf-not-delete pattern as PATCH /reviews/{id}: a timestamp under the hood, a boolean in the wire shape) without touching its images or files; GET /notes shows live notes only by default, so an archived one drops out of the normal list until asked for. Moving the note to another client drops a project that belonged to the old client, and the response names it in "dropped_project". Sending no field is 400; sending values the note already holds writes nothing and returns "unchanged": true, so a retried PATCH is harmless. A note always keeps a client or a project: personal notes are made in the app.
Request body
{ "title"?: "string | null", "body"?: "string", "pinned"?: true, "color"?: "red | amber | green | teal | blue | purple | #rrggbb | null", "archived"?: true, "client_id"?: "uuid", "project_id"?: "uuid | null" }
Example response
{ "note": { "id": "uuid", "title": "…", "body": "…", "pinned": true, "archived": false, "updated_at": "2026-09-17T03:20:00Z" } }
DELETE
/notes/{id}
notes:write
Delete a note YOU wrote (403 otherwise). Its images are deleted with it. There is no undo.
Example response
{ "deleted": { "id": "uuid", "title": "Invoicing: always to accounts@", "client": "Cavalle & Co", "images_removed": 0 } }
Chats
Read and post into the per-client team chat — plain text, inline images, and document/HTML/code attachments. This is how you deliver finished work into a conversation. Rule of thumb: real deliverables (an HTML page, a PDF, an image) are sent as FILES via the attachments endpoint so they render as a proper preview — not pasted as text.
GET
/chats
chats:read
Client chats with a last-message preview.
Example response
{ "chats": [ { "client_id": "uuid", "client": "Serene Heat", "last": { "sender": "Lachlan", "preview": "its pre much ready", "at": "2026-06-20T02:30:40Z" } } ] }
GET
/chats/{clientId}/messages
chats:read
Recent messages, oldest→newest. Each has a raw `body` plus, when the message carries a file, a parsed `attachment` object so you do not have to decode the body yourself.
- You normally just read `attachment` (above). If you decode `body` yourself: plain text is the text; an IMAGE message is "🖼 <url>" on the first line + optional caption below; a FILE message is "📎 <filename>" + optional caption (matched to its stored file by name); an inline code/HTML snippet is a fenced block ```lang:filename … ``` (lang `md` just groups it under the Markdowns panel — it still renders as a code card).
- Mentions read as "@Name", links are bare URLs. `is_edited` flags edited messages; deleted messages are omitted from the response.
- limit
- number, optional — default 50
- before
- ISO timestamp, optional — page backwards through history
Example response
{ "messages": [
{ "id": "uuid", "sender": "Zac", "body": "can we ship friday?", "created_at": "2026-06-27T07:40:00Z" },
{ "id": "uuid", "sender": "Zac", "body": "🖼 https://…/poster.png\ntest image",
"attachment": { "type": "image", "url": "https://…/poster.png", "file_name": "poster.png", "caption": "test image", "width": 1024, "height": 1024 }, "created_at": "2026-06-27T07:42:00Z" },
{ "id": "uuid", "sender": "Lachlan", "body": "📎 brief.pdf",
"attachment": { "type": "file", "url": "https://…/brief.pdf", "file_name": "brief.pdf", "mime_type": "application/pdf" }, "created_at": "2026-06-27T08:00:00Z" }
] }
POST
/chats/{clientId}/messages
chats:write
Send a plain-text message (posts as the key owner). Use this for conversation only — do NOT paste a whole HTML page or built file in here as text. To deliver an actual file (HTML, image, PDF, doc), use the attachments endpoint below so it renders properly.
- A short code/HTML SNIPPET you want shown as a card can be wrapped in a fenced block — ```js:utils.js\nconst x = 1\n``` (or ```md:notes.md for markdown). This is for illustrative snippets only; a real HTML deliverable still goes through /attachments as a .html file.
Request body
{ "body": "string" }
Example response
{ "message": { "id": "uuid", "created_at": "2026-06-27T09:00:00Z" } }
POST
/chats/{clientId}/attachments
chats:write
Deliver a FILE into the chat — an image, an HTML page, a PDF/doc, anything. Base64-encode the bytes; the file is stored and posted as a message by the key owner. THIS is how you hand over work: e.g. for "send the Gala report to the chat", build report.html and POST it here as a .html file — never paste the markup into a text message.
- Send the FILE, not its source. An .html file posted here renders as a live preview card (Preview / Download) inside the chat; pasting the same HTML as text would just show raw code and is wrong.
- Images (png/jpg/webp/gif/svg…) render INLINE, sized to their true aspect ratio (the endpoint reads width/height), and also appear in the chat’s Images panel.
- Source-code files (.js/.ts/.py/.css/.json…) render a syntax-preview card; every other type is a clean download card.
- `mime_type` is optional — it is sniffed from `filename` when omitted. `caption` is an optional line shown beneath the file.
- Under the hood the post is a message whose body is "🖼 <url>" (images) or "📎 <filename>" (other files); reading it back returns the parsed `attachment` shown on the GET endpoint.
Request body
{ "filename": "report.html", "content_base64": "<base64 of the file bytes>", "mime_type": "text/html", "caption"?: "string" }
Example response
{ "attachment": { "url": "https://…/report.html", "file_name": "report.html", "mime_type": "text/html" }, "message": { "id": "uuid" } }
Client Approvals (Reviews)
Create and manage client-approval batches — Meta ad creatives and HTML emails — the same batches the Approvals pages manage in-app. Each batch has a shareable client review_url.
GET
/reviews
reviews:read
All review batches with per-item counts and the client-facing review_url. Each batch also carries review_due_date (the day the client sign-off is due) and in_ads_date (the day the approved creatives go live in ads, or, on an email batch, the day the email is planned to be sent in Klaviyo, a plan and never a record that it went out).
- kind
- 'creative' | 'email', optional
- client_id
- uuid, optional
Example response
{ "batches": [ { "id": "uuid", "title": "Gala September creatives", "kind": "creative", "status": "draft", "client": "Gala Equine", "review_url": "https://…/review/<token>", "counts": { "items": 3, "approved": 1, "changes_requested": 0 }, "review_due_date": "2026-09-09", "in_ads_date": "2026-09-12" } ] }
GET
/reviews/{id}
reviews:read
One batch with every item: Meta copy fields, media URLs, per-item status, ad set name, confirmed/submitted state (emails include their html), plus the batch's review_due_date and in_ads_date (on an email batch that date is the planned Klaviyo send day, not a record that it went out). Each media entry carries its version — a new upload becomes v2 on the same item and the same review link, and only the highest version is live. Every non-email item also carries reference: the ad the creative was modelled on, its file (image or video), the brand that ran it and its context (why this reference was chosen), or null when it has none. Team only: the client never sees it.
Example response
{ "batch": { "id": "uuid", "title": "…", "items": [ { "id": "uuid", "meta_ad_name": "GALA-RIPSTOP-01", "ad_set": "Bubblegum Collection", "confirmed": true, "headline": "One Rug. Four Seasons.", "ad_copy": "…", "description": "…", "cta_text": "Shop Now", "cta_url": "https://…", "creative_reason": "Tests the price-anchor angle on the hero SKU.", "status": "pending", "media": [ { "url": "https://…", "version": 2 } ], "reference": null } ] } }
GET
/reviews/items/{id}
reviews:read
One creative by its uuid — the ID the app's ID chip copies. Full copy, media, ad set, batch context and all feedback/team notes. Plus reference: the ad this creative was modelled on, its file (kind 'image', or 'video' for a Reference video; null when no file is saved), the brand that ran it (brand_name) and why this reference was chosen (context, e.g. Built on Young Nails ad #1 · 330 days running · impression rank 2 · still live), or null when it has none. Optional and team only: never shown to the client, never a blocker for confirm or publish.
Example response
{ "item": { "id": "uuid", "meta_ad_name": "…", "ad_set": "…", "confirmed": false, "reference": { "brand_name": "Example Saddlery", "context": "Built on Young Nails ad #1 · 330 days running · impression rank 2 · still live", "kind": "video", "url": "https://…/review-media/<batch>/<item>/reference/api-1758067200000.mp4", "thumb_url": null, "display_url": null, "mime_type": "video/mp4", "file_name": "api-1758067200000.mp4", "file_size": 8421337, "width": null, "height": null, "updated_at": "2026-09-17T04:12:00Z" }, "batch": { "id": "uuid", "title": "…", "client": "Gala Equine" }, "feedback": [ { "author": "Zac", "from": "team", "message": "change the price", "pinned": true } ] } }
GET
/reviews/adsets
reviews:read
A client's ad sets, each listing the creatives filed in it (name, status, confirmed).
- client_id
- uuid, required
Example response
{ "ad_sets": [ { "id": "uuid", "name": "Bubblegum Collection", "items": [ { "id": "uuid", "name": "GALA-RIPSTOP-01", "status": "pending", "confirmed": true } ] } ] }
POST
/reviews
reviews:write
Create a draft batch for a client. review_due_date is the calendar day the client's sign-off is due. in_ads_date is the calendar day the approved creatives go live in ads, or, on an email batch, the day the email is planned to be sent in Klaviyo (a plan, not a record that it went out). Both optional, both YYYY-MM-DD — leave them out and the batch simply carries no dates.
Request body
{ "title": "September creatives", "client_id": "uuid", "kind": "creative", "review_due_date": "2026-09-09", "in_ads_date": "2026-09-12" }
Example response
{ "batch": { "id": "uuid", "status": "draft", "review_due_date": "2026-09-09", "in_ads_date": "2026-09-12", "review_url": "https://…/review/<token>" } }
PATCH
/reviews/{id}
reviews:write
Rename a batch, set its dates, and/or archive it. Archiving is what you do INSTEAD of deleting a batch the client has responded to — nothing is destroyed, it just leaves the working list, and {"archived": false} brings it back. review_due_date is the day the client's sign-off is due, in_ads_date the day the approved creatives go live in ads (on an email batch, the day it is planned to be sent in Klaviyo): send either as YYYY-MM-DD, or null to clear it; omit it and it stays as it is. Statuses move via the app / client actions.
Request body
{ "title": "New name", "review_due_date": "2026-09-09", "in_ads_date": "2026-09-12", "archived": true }
POST
/reviews/{id}/items
reviews:write
Add an ad (or email) to a batch. item_type is chosen HERE (static by default): once anything is in the creative (media, copy or a reference) it can no longer change, so pick the type you mean. Pass template_id to pre-fill from the client's Meta templates; media_url (https image/video) is downloaded, stored as the creative and thumbnailed automatically. Ready = media + ad_copy + headline + valid https cta_url. creative_reason is OPTIONAL — a note shown to the client on the review page explaining why the creative was made; it never gates confirm or publish. The REFERENCE is the ad this creative was modelled on, OPTIONAL and TEAM ONLY (never shown to the client, never a blocker): its file, reference_media_url, an https image or video downloaded by the same rules as media_url (a video shows as the Reference video); the brand that ran it, reference_brand_name (max 200 characters); and why this reference was chosen, reference_context (e.g. Built on Young Nails ad #1 · 330 days running · impression rank 2 · still live; several lines are fine, max 2000 characters). Send any mix of the three. An email or a general image (item_type brand_image) takes no reference: 400, and nothing is made. If the reference file will not download or store once the item exists, the item still comes back 201 with a warning and no reference, so send it again with PATCH.
Request body
{ "template_id": "uuid", "cta_url": "https://…", "media_url": "https://…", "creative_reason": "Tests the price-anchor angle on the hero SKU.", "reference_media_url": "https://…/the-ad-it-was-modelled-on.mp4", "reference_brand_name": "Example Saddlery", "reference_context": "Built on Young Nails ad #1 · 330 days running · impression rank 2 · still live" }
PATCH
/reviews/items/{id}
reviews:write
Edit an ad/email — same fields as create, plus meta_ad_name and ad_set_id (must belong to the batch's client — GET /reviews/adsets). media_url becomes the next VERSION of the creative on a non-carousel ad (v2, v3… on the same item and the same review link; the cut the client already saw stays as history) and appends a slide on a carousel. item_type is chosen at creation and can only change while the creative is still BLANK: no media of any version, no ad_copy, headline, description or cta_url (blank once trimmed) and no reference. Once anything is in it, a different item_type is refused with 409 before anything is written, so create a new item of the type you want instead. Sending the type it already has is fine, and the check reads the item as it stood before the call, so one PATCH can set a blank item's type and fill in its copy together. The reference (the ad this creative was modelled on: its file, the brand that ran it and why it was chosen; optional, team only): reference_brand_name is trimmed, and null or "" clears it; reference_context (why this reference was chosen, e.g. Built on Young Nails ad #1 · 330 days running · impression rank 2 · still live; max 2000 characters) keeps its inner line breaks, is trimmed at both ends, and null, "" or only whitespace clears it; reference_media_url replaces the reference file with a new https image or video (the old file is deleted only once the new one is saved), or null removes the file and keeps the rest. Send only the fields to change. A reference left with no brand, no context and no file is deleted, and a bad reference value is a 400 before anything on the item is written. A CONFIRMED item is locked: every field except creative_reason, reference_brand_name, reference_context and reference_media_url is refused with 423, because a confirmed creative is what goes to the client. Unlock it in the app first. The reference stays editable while confirmed because the client never sees it. confirmed (true|false) confirms or unconfirms this one asset and is ADMIN KEYS ONLY (403 otherwise); it used to be silently ignored.
Request body
{ "headline": "New headline", "ad_set_id": "uuid", "reference_brand_name": "Example Saddlery", "reference_context": "Built on Young Nails ad #1 · 330 days running · impression rank 2 · still live", "reference_media_url": null }
DELETE
/reviews/{id}
reviews:write
Delete a batch with its items, media (reference files included) and feedback. REFUSED with 409 once the client has left ANY feedback — that feedback is the record of the job, so archive it instead (PATCH {archived:true}); a database trigger enforces this even if you call it directly. Otherwise a draft goes straight away, and once a batch has left draft its review link is already with the client, so it refuses with 409 unless you repeat the call with ?force=true.
- force
- 'true', only needed on a non-draft batch
Example response
{ "deleted": { "id": "uuid", "title": "…", "items": 4, "files": 4, "reference_files": 1, "was_status": "draft" } }
POST
/reviews/{id}/confirm
reviews:write
Confirm every asset on the batch in one call — what an agent should finish with after building one. ADMIN KEYS ONLY (403 otherwise): confirming is the moment an asset becomes the thing that goes to the client, so it is the same admin-only rule the app enforces, now checkable for a key. Body `{confirmed:false}` unconfirms instead. Returns `items` (how many rows were written) and `already` (how many were in that state before you asked). 409 when the batch has nothing on it. A batch cannot leave draft while anything on it is unconfirmed — the database refuses it, so confirm first, then make it public.
Request body
{ "confirmed": true }
Example response
{ "ok": true, "batch_id": "uuid", "title": "…", "confirmed": true, "items": 19, "already": 0 }
DELETE
/reviews/items/{id}
reviews:write
Delete one ad/email from a batch, including the creative and reference files stored for it. A confirmed item is refused with 423 — unlock it in the app first.
Example response
{ "deleted": { "id": "uuid", "title": "…", "batch_id": "uuid" } }
GET
/reviews/templates
reviews:read
A client's saved Meta copy templates — use ids as template_id.
- client_id
- uuid, required
Contracts (agreements and invoices)
A PDF per client, signed from a link on their phone: an agreement to sign or an invoice to accept, the same rows the Contracts page manages. Build the PDF yourself and file it here; this is the default way a contract is made. Manager keys only (an admin or an agency owner).
GET
/contracts
contracts:read
The agency's contracts, newest first: kind, status (draft, sent, viewed, signed, declined, void, expired, paid), client, title, price_line, signer, sign_url, the stamps, app_url.
- client_id
- uuid, or client = a name / nickname
- status
- draft | sent | viewed | signed | declined | void | expired | paid, or open = sent + viewed
- kind
- agreement | invoice
- limit
- default 100, max 200
Example response
{ "count": 2, "contracts": [ { "id": "uuid", "kind": "invoice", "status": "viewed", "client": "Kez Rugs", "title": "Invoice INV-0042", "price_line": "AUD 1,500 one-off", "signer": { "name": "Kez", "email": "kez@kezrugs.com.au", "company": "Kez Rugs" }, "sign_url": "https://happen.makeitscale.com.au/sign/…", "view_count": 1, "app_url": "https://happen.makeitscale.com.au/#contracts/uuid" } ] }
GET
/contracts/{id}
contracts:read
One contract with its events (sent, viewed, signed, declined, voided, every email outcome) and one-hour signed links: pdf_url (the document) and signed_pdf_url (the signed copy with its certificate page, once signed).
Example response
{ "contract": { …, "status": "signed", "signed_at": "…", "signed_by": "Kez", "signed_pdf_url": "https://…" }, "events": [ { "kind": "sent", "actor": "agency", … }, { "kind": "viewed", "actor": "signer", "ip": "…" }, { "kind": "signed", … } ] }
POST
/contracts
contracts:write
File a PDF as a contract for a client (201). THE DEFAULT WAY IN: build the invoice or agreement yourself, in the client's folder, then file it here. The PDF is opened and its pages counted (a damaged one is refused); up to 15 MB. The signer defaults to the client's first contact with an email. send:true emails the signer the link at once; otherwise it is a draft whose link says 'not ready' until POST /contracts/{id}/send.
Request body
{ "client_id": "uuid (or client: a name)", "kind": "invoice | agreement (default)", "pdf_base64": "…", "pdf_name": "INV-0042.pdf", "title": "Invoice INV-0042", "invoice_number": "INV-0042", "amount": 1500, "currency": "AUD", "billing": "one_off", "due_on": "2026-10-05", "signer_name": "Kez", "signer_email": "kez@kezrugs.com.au", "message": "Here is this month's invoice.", "send": true }
Example response
{ "contract": { "id": "uuid", "kind": "invoice", "status": "sent", "sign_url": "https://happen.makeitscale.com.au/sign/…", … }, "sign_url": "…", "email": { "status": "sent", "id": "resend id" } }
POST
/contracts/{id}/send
contracts:write
Email the signer the signing link: a draft becomes sent, an already-sent one gets a reminder. 409 on a signed, declined, voided or expired contract.
Example response
{ "contract": { "status": "sent", … }, "sign_url": "…", "email": { "status": "sent" } }
PATCH
/contracts/{id}
contracts:write
Void an unsigned contract (the link dies at once), mark a signed one paid or unmark it, or edit a DRAFT. A sent contract is frozen: its words, price, PDF and signer refuse to change (409); void it and file a new one.
Request body
{ "status": "void", "void_reason": "price changed" } or { "paid": true, "paid_note": "bank transfer 5 Oct" } or, on a draft, any of title, amount, currency, billing, billing_note, term_months, starts_on, invoice_number, due_on, message, signer_name, signer_email, signer_company, expires_days, kind
DELETE
/contracts/{id}
contracts:write
Delete a draft, voided or declined contract with its files. A sent or signed one is refused with 409: a signed contract is a record.
Example response
{ "deleted": { "id": "uuid", "title": "…", "files_removed": 1 } }
Lead CRM
An agency’s own tables for tracking leads — built like a Google Sheet in the app (typed columns, a Status column with pill choices), reachable here so an agent can learn a table’s shape and add rows straight into it. ALWAYS GET /crm/tables (or GET /crm/tables/{id}) first to read a table’s real columns before writing — never guess a name or invent one. Rows belong to anyone who can see the table (crm:write); creating, renaming or deleting a TABLE or a COLUMN is manager keys only (an admin or an agency owner) — a team key reads structure and works rows but cannot change it. A "managers" table is invisible to anyone else, including through this API (404, not 403 — it never confirms one exists).
GET
/crm/tables
crm:read
Every table your org can see, each with its columns (id, name, type, options — a select column’s choices, with their ids, labels and colours — width, position) inline, and row_count. Sorted by position then name.
Example response
{ "count": 1, "tables": [ { "id": "uuid", "name": "Google Maps leads — dog groomers", "client_id": null, "client": null, "visibility": "org", "lead_source": null, "row_count": 214, "columns": [ { "id": "uuid", "name": "Business", "type": "text", "options": {}, "width": 200, "position": 1000 }, { "id": "uuid", "name": "Status", "type": "select", "options": { "choices": [ { "id": "a1b2c3d4e", "label": "New", "color": "#60a5fa" }, { "id": "f6g7h8i9j", "label": "Contacted", "color": "#fbbf24" } ] }, "width": 140, "position": 2000 } ], "app_url": "https://makeithappen-one.vercel.app/#lead-crm/uuid" } ] }
POST
/crm/tables
crm:write
Create a table (201). Manager keys only (403 otherwise). name must be unique in the org, ignoring case (409 + existing:{id,name} on a clash). client_id must be one of GET /clients’ ids in your org, or left out (404 on an unknown one). Up to 40 initial columns, each named uniquely ignoring case/punctuation (400 on a clash); a select column’s options is {"choices": ["Label" or {"label","color"?}, …], "chips"?}. width defaults from the column’s type and name when not sent.
Request body
{ "name": "Google Maps leads — dog groomers", "client_id": null, "visibility": "org", "columns": [ { "name": "Business", "type": "text" }, { "name": "Phone", "type": "phone" }, { "name": "Website", "type": "url" }, { "name": "Status", "type": "select", "options": { "choices": ["New", "Contacted", "Booked"] } } ] }
Example response
{ "table": { "id": "uuid", "name": "Google Maps leads — dog groomers", "row_count": 0, "columns": [ … ], "app_url": "https://makeithappen-one.vercel.app/#lead-crm/uuid" } }
GET
/crm/tables/{id}
crm:read
One table with its columns and row_count.
PATCH
/crm/tables/{id}
crm:write
Rename a table, move it to another client, or change its visibility. Manager keys only. Send only the fields to change; a name clash with another table in the org is 409.
Request body
{ "name": "Google Maps leads — mobile groomers", "client_id": "uuid", "visibility": "managers" }
DELETE
/crm/tables/{id}
crm:write
Delete a table with its columns and rows. Manager keys only. Refused with 409 (naming the row count) unless the table is empty or ?force=true is sent.
- force
- 'true' — required once the table has any rows
Example response
{ "deleted": { "id": "uuid", "name": "…", "rows": 214 } }
POST
/crm/tables/{id}/columns
crm:write
Append one or more columns to an existing table (201, returns the table’s full column list). Manager keys only. 400 once the table would pass 40 columns; 409 on a name that already exists (ignoring case/punctuation) naming the clash.
Request body
{ "columns": [ { "name": "Suburb", "type": "text" }, { "name": "Budget", "type": "currency", "options": { "currency": "AUD" } } ] } — or a single column object, not wrapped in "columns"
PATCH
/crm/columns/{id}
crm:write
Rename a column, resize it (width, 60-800), or — on a select column — replace its choice list. Manager keys only. type can’t be changed here: 400, pointing at the app (it converts every value and reports what can’t make the trip). Replacing choices KEEPS the id of any choice whose label still matches, so cells already set to it stay correct; dropping a choice still stored on any row is refused with 409 naming how many rows use it.
Request body
{ "name": "Stage", "width": 160, "options": { "choices": ["New", "Contacted", "Booked", "Won"] } }
DELETE
/crm/columns/{id}
crm:write
Delete a column. Manager keys only. Strips its value from every row in the same transaction.
Example response
{ "deleted": { "id": "uuid", "name": "Budget", "rows_cleared": 37 } }
GET
/crm/tables/{id}/rows
crm:read
A table’s rows. Each row’s data is keyed by COLUMN NAME with display values — a select cell shows its choice LABEL, a checkbox is always true/false, an absent cell is null; cells is the same row keyed by column ID with the raw stored values (what a PATCH takes). count is the table’s total row count (not affected by q); returned is how many rows are in THIS response.
- limit
- default 200, max 1000
- offset
- default 0
- q
- text, optional — case-insensitive substring match across every cell, scanned over up to 5000 rows
- updated_since
- ISO timestamp, optional — only rows touched since then
Example response
{ "count": 214, "returned": 2, "offset": 0, "columns": [ { "id": "uuid", "name": "Business", "type": "text" }, { "id": "uuid", "name": "Status", "type": "select" } ], "rows": [ { "id": "uuid", "position": 1000, "created_at": "2026-09-23T01:00:00Z", "updated_at": "2026-09-23T01:00:00Z", "data": { "Business": "Bubbles Dog Spa", "Status": "New" }, "cells": { "colUuid1": "Bubbles Dog Spa", "colUuid2": "a1b2c3d4e" } } ] }
POST
/crm/tables/{id}/rows
crm:write
Add leads in bulk (201, max 500 rows per call — 413 above it). Each value is coerced to its column's type (numbers, dates, checkboxes, a select's choice by label or id) — a value that can't be written is named in that row's skipped[] with why (an ambiguous number like '1234,50', a date that isn't real, an unknown select choice on a non-manager key), and a key that matches no column at all is collected once in columns_unknown and NEVER used to invent one — POST /crm/tables/{id}/columns first. Dedupes per table on external_id: give every lead its own (e.g. a Google Place id, a form submission id) and a re-run of the same scrape comes back 'duplicate' instead of a second row; omit it and every call makes new rows. quiet defaults true, so a batch never emails the client once per row — send quiet:false only for a single real-time lead.
Request body
{ "rows": [ { "data": { "Business": "Bubbles Dog Spa", "Phone": "0400 000 000", "Status": "New" }, "external_id": "gplace:ChIJ…" }, { "data": { "Business": "Pawfect Grooming" }, "external_id": "gplace:ChIJ…" } ], "quiet": true }
Example response
{ "added": 1, "duplicates": 1, "skipped": 0, "results": [ { "index": 0, "result": "added", "row_id": "uuid", "skipped": [] }, { "index": 1, "result": "duplicate", "row_id": "uuid", "skipped": [] } ], "columns_unknown": [] }
PATCH
/crm/rows/{id}
crm:write
Merge-patch one row’s cells — send only what changes; null clears a cell. Same per-value coercion and skipped[] reporting as POST rows. Returns the row in the same shape as the GET (data + cells).
Request body
{ "data": { "Status": "Contacted", "Notes": null } }
Example response
{ "row": { "id": "uuid", "data": { "Business": "Bubbles Dog Spa", "Status": "Contacted" }, "cells": { … } }, "skipped": [], "columns_unknown": [] }
DELETE
/crm/rows/{id}
crm:write
Delete one row. Also clears its dedupe mark (crm_inbound_leads), so the same external_id can be re-added later — the app’s own delete in the sheet leaves that mark in place, so it knows the difference between "never sent" and "deleted on purpose".
Example response
{ "deleted": { "id": "uuid" } }
SOPs
The agency’s own library of standard operating procedures — a "mini Google Drive" of folders (services) holding SOPs: an uploaded PDF, or a formatted document you write straight into the app. Read by the whole team (admin, agency or team access level — a reviewer’s key is 403); written by the agency’s owner or its managers only (admin or agency access level — a team key is 403). GET /sops/folders first, always, to see the tree — it comes back flat, build it yourself on parent_id.
GET
/sops/folders
sops:read
Every folder in the library, flat (nest them yourself on parent_id — up to 5 levels deep). Each carries sop_count (SOPs filed directly in it) and app_url.
Example response
{ "count": 2, "folders": [ { "id": "uuid", "name": "Onboarding", "description": null, "parent_id": null, "sort_order": 0, "sop_count": 3, "created_at": "…", "updated_at": "…", "app_url": "https://happen.makeitscale.com.au/#sops/f/uuid" } ] }
POST
/sops/folders
sops:write
Create a folder (201). Manager keys only. parent_id must be one of GET /sops/folders’ ids (404 on an unknown one); more than 5 levels deep or a folder inside itself is refused with the database’s own message.
Request body
{ "name": "Onboarding", "description": "How we bring a new client on", "parent_id": null }
PATCH
/sops/folders/{id}
sops:write
Rename, redescribe, re-parent (parent_id: null = top level) or reorder a folder. Manager keys only. Send only what changes.
Request body
{ "name": "Client Onboarding" }
DELETE
/sops/folders/{id}
sops:write
Delete an EMPTY folder. Manager keys only. 409, naming how many subfolders and SOPs it still holds, when it isn’t empty — move or delete what’s inside first.
GET
/sops
sops:read
The agency’s SOPs, most recently edited first. Each row carries folder_name, files_count, source ("app" | "api") and app_url — never the full body_html or a signed pdf_url; GET /sops/{id} reads one in full.
- folder_id
- uuid, or "root" for unfiled SOPs
- q
- text, optional — matches title, description or the document body, case-insensitive
- kind
- pdf | doc
- limit
- default 50, max 200
- offset
- default 0
Example response
{ "count": 12, "returned": 12, "offset": 0, "sops": [ { "id": "uuid", "title": "New client kickoff call", "kind": "doc", "folder_id": "uuid", "folder_name": "Onboarding", "files_count": 2, "source": "app", "updated_at": "…", "app_url": "https://happen.makeitscale.com.au/#sops/s/uuid" } ] }
GET
/sops/{id}
sops:read
One SOP in full: body_html (a "doc" SOP’s formatted content — the app renders it through its document schema: h2/h3, bold, italic, lists, quotes, links, code, images; an inline image is <img src="sop-file:<file id>">), pdf_url (a "pdf" SOP’s file, signed for one hour), path (the folder chain from the root, [{id,name}]) and files (attachments and inline images, each with a one-hour signed url).
Example response
{ "sop": { "id": "uuid", "title": "New client kickoff call", "kind": "doc", "body_html": "<h2>Before the call</h2><p>…</p>", "pdf_url": null, "path": [ { "id": "uuid", "name": "Onboarding" } ], "files": [ { "id": "uuid", "role": "inline", "file_name": "checklist.png", "url": "https://…", "width": 1200, "height": 800 } ], "app_url": "https://happen.makeitscale.com.au/#sops/s/uuid" } }
POST
/sops
sops:write
File a new SOP (201, returned in full like GET /sops/{id}). Manager keys only. A doc SOP needs a title (body_html is optional, starts blank, 1 MB cap — 413 above it). A pdf SOP needs pdf_base64 or an https pdf_url (fetched with a timeout and a 25 MB cap — 413 above it), is opened and its pages counted (a damaged or encrypted PDF is refused with 400), and its title defaults to the file name with ".pdf" dropped. folder_id is optional (unfiled when left out; 404 on an unknown one).
Request body
{ "folder_id": "uuid", "title": "New client kickoff call", "kind": "doc", "body_html": "<h2>Before the call</h2><p>Confirm the brief…</p>" } — or { "kind": "pdf", "pdf_base64": "…", "pdf_name": "Kickoff-checklist.pdf" }
PATCH
/sops/{id}
sops:write
Edit an SOP. Manager keys only. kind in the body is always 400 — it never changes, make a new SOP instead; body_html is refused on a pdf SOP and pdf_base64/pdf_url are refused on a doc SOP. Replacing a pdf SOP’s file uploads the new one and updates the row before removing the old object, so a failed upload or write never touches what was there.
Request body
{ "title": "…", "description": "…", "folder_id": "uuid or null", "sort_order": 1 } — or, on a pdf SOP, a replacement: { "pdf_base64": "…", "pdf_name": "v2.pdf" }
DELETE
/sops/{id}
sops:write
Delete an SOP: the row (its files cascade in the database), then every storage object it owned — the PDF plus every attached or inline file.
Example response
{ "deleted": true, "files_removed": 2 }
POST
/sops/{id}/files
sops:write
Attach a file to an SOP, or add an image to reference inline in a doc’s body_html (role:"inline", then write <img src="sop-file:<the returned file id>"> where it should appear). Manager keys only. PNG, JPEG, GIF, WebP or PDF only — checked by the file’s own bytes, never by its name or a sent mime_type; 25 MB cap (413 above it). Returns the file with a one-hour signed url.
Request body
{ "file_base64": "…", "file_name": "checklist.png", "role": "inline", "caption": "The onboarding checklist" }
DELETE
/sops/files/{id}
sops:write
Remove one file from an SOP: the row, then its storage object. Manager keys only.
Worked example — plan today
Copy-paste bash. $MIH_API_KEY = the user’s key.
# Plan today end-to-end (bash). $MIH_API_KEY = the user's key.
DAY=2026-06-28
# 1. where's the free time?
curl -s "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/availability?date=$DAY" \
-H "Authorization: Bearer $MIH_API_KEY"
# 2. what needs doing?
curl -s "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/tasks?scheduled=false" \
-H "Authorization: Bearer $MIH_API_KEY"
# 3. lay the chosen tasks into the day in one call
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/plan" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"date":"'$DAY'","task_ids":["<uuid1>","<uuid2>"]}'
# 4. or place a single block precisely
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/blocks" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"task_id":"<uuid>","work_date":"'$DAY'","start_time":"09:00","end_time":"09:30"}'
# 5. tick a block done (or rename / re-tag it) — PATCH only the fields you change
curl -s -X PATCH "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/blocks/<id>" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"status":"completed"}'
# 6. create a NEW client task and block it — work_type is required, and on a real
# client it must be one of deliverable/communication/sales/revisions/admin
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/blocks" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"title":"Send Gala the September creatives","client_id":"<uuid>","work_type":"deliverable","work_date":"'$DAY'","start_time":"10:00","end_time":"10:45"}'
# 7. log something you ALREADY did (lands as completed, not not_started)
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/blocks" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"title":"Gym","client_id":"personal","work_date":"'$DAY'","start_time":"06:30","end_time":"07:15","status":"completed"}'
# ── Deliver a built HTML report into a client chat ──
# Right way: send the FILE so it renders as a preview card. Do NOT paste the HTML as a text message.
CLIENT=<clientId>
B64=$(base64 -i report.html) # the file you built, base64-encoded
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/chats/$CLIENT/attachments" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"filename":"report.html","mime_type":"text/html","content_base64":"'$B64'","caption":"June performance report"}'
# Send an image the same way (renders inline):
# -d '{"filename":"poster.png","content_base64":"'$(base64 -i poster.png)'"}'
# Plain text is just conversation — not for delivering files:
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/chats/$CLIENT/messages" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"body":"Report is in 👆 let me know if you want changes"}'
# ── Work a task as an agent, and land everything IN the task ──
# 1. what's outstanding? (owner's open backlog, due-date order, with estimates)
curl -s "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks" -H "Authorization: Bearer $MIH_API_KEY"
# 1b. nothing in the backlog matches the work? make its task, placed at the current time
# (201; if that span is taken the task is created untimed and the conflicts come back)
TASK=$(curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"title":"Kez Rugs: homepage hero swap","client_id":"'$CLIENT'","work_type":"deliverable","estimate_min":45,"schedule":"now","status":"working_on_it"}' | jq -r .task.id)
# …and when the person says how long it will really take:
curl -s -X PATCH "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks/$TASK" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"estimate_min":60}'
# 2. read the brief: description, files (fetch their urls), sub-items, past runs
TASK=<taskId>
curl -s "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks/$TASK" -H "Authorization: Bearer $MIH_API_KEY"
# 3. start a run — the task's Agent panel goes live from here
RUN=$(curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks/$TASK/agent/runs" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"title":"Build the landed-cost sheet","model":"claude-opus-5"}' | jq -r .run.id)
# 4. log as you go (short, frequent lines — the person is watching)
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks/agent/runs/$RUN/events" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"kind":"log","body":"Read the supplier invoice: 42 lines"}'
# 5a. hand back an ARTIFACT the task renders (a report, a board, a client email draft).
# The HTML is stored as text and previewed in a sandboxed iframe, with Copy HTML +
# Download on the card, so it can be taken straight out and sent.
python3 -c "import json,sys;print(json.dumps({'kind':'html','title':'Lisa - events page email','caption':'Ready to send','html':open('email.html').read(),'run_id':'$RUN'}))" > /tmp/res.json
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks/$TASK/resources" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
--data-binary @/tmp/res.json
# 5b. file the deliverable FILE on the task — a PDF for anything detailed
B64=$(base64 -i landed-cost.pdf)
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks/$TASK/attachments" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"filename":"landed-cost.pdf","content_base64":"'$B64'","run_id":"'$RUN'","caption":"Landed cost per SKU"}'
# 6. close the run with a short summary (posted as the closing reply)
curl -s -X PATCH "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/tasks/agent/runs/$RUN" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"status":"done","summary":"Sheet attached as PDF. 3 SKUs had no supplier cost, flagged on page 2."}'
# ── Save durable client context as a NOTE (it shows in the app's Keep page, under that client) ──
# For a decision, a preference, a standing fact about the client. NOT for task logs or run
# output: those belong on the task. The body is plain text or markdown, stored exactly as sent.
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/notes" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" \
-d '{"client_id":"'$CLIENT'","title":"Invoicing: always to accounts@","body":"Lisa wants every invoice sent to accounts@ with the PO number in the subject line.\n\nAgreed on the 17 Sep call.","pinned":true,"color":"amber"}'
# A longer note from a file, sent byte for byte (jq builds the JSON, so quotes and newlines survive):
jq -Rs --arg c "$CLIENT" '{client_id: $c, title: "Brand voice", body: .}' brand-voice.md | \
curl -s -X POST "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/notes" \
-H "Authorization: Bearer $MIH_API_KEY" -H "Content-Type: application/json" --data-binary @-
# Read a client's notes back (previews), then one in full:
curl -s "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/notes?client_id=$CLIENT" -H "Authorization: Bearer $MIH_API_KEY"
curl -s "https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/notes/<noteId>" -H "Authorization: Bearer $MIH_API_KEY"