Agent reference

MakeItHappen API

If you are an AI agent: this is your manual for operating this user’s MakeItHappen workspace. Authenticate every request with the user’s API key (below), then use these endpoints to read their world and plan their day. Follow the Quickstart to build a full day. Obey the Rules — most importantly, you may READ everyone’s data but you may only WRITE the key owner’s own grid. Tip for agents: GET the Base URL itself (no key) and it returns this entire reference as JSON — fetch it first to confirm the exact endpoints, then call them with the key.

Base URL https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api
Auth Authorization: Bearer mih_live_…

Every request needs the user’s personal API key in the Authorization header. Get it from the in-app API page (each user sees only their own key). A key only ever sees what its scopes allow.

Agent tip: this page is static HTML, so the full reference is in this raw response. You can also GET the Base URL itself (no key) to receive the same reference as JSON — fetch it first to confirm the exact endpoints, then call them with the key.

Rules (read these first)

  1. CONTRACTS: to get a client to sign an agreement or accept an invoice, build the PDF yourself, then POST /contracts {client_id, kind, pdf_base64, send:true}. The signer is emailed a link they sign on their phone; GET /contracts/{id} follows it (viewed, signed, declined) and carries the signed copy. A sent contract is FROZEN: void it and file a new one to change anything. A signed one is never deleted. Manager keys only.
  2. READ is global, WRITE is owner-only. A key can read any teammate’s tasks/days for context, but can only create/move/delete blocks on its OWN owner’s grid. Banjo’s key can never edit Zac’s tasks and vice versa — the server rejects it.
  3. No financial data, ever. No income, splits, expenses, rates or invoices are exposed by any endpoint. Clients return name + contacts + resources only.
  4. Scopes gate everything. A request outside the key’s scopes returns 403. Check the user’s scopes on the in-app API page.
  5. Deliver files AS FILES. To hand a build into a chat (HTML page, image, PDF, doc), POST it to /chats/{clientId}/attachments as a base64 file — never paste the markup or code into a text message. Sent as a file, an HTML page renders as a live preview card; pasted as text it is just raw code and is wrong.
  6. Mark work done through the API. Set "status":"completed" on a block — on POST to log work you have already done, or PATCH an existing block to tick it off. Valid statuses: not_started, next_up, working_on_it, completed (you may send aliases like "done" or "in_progress"). You can also rename a block (title) or re-tag its client/project on PATCH — no need to delete and recreate.
  7. Tag non-client personal time with "client_id":"personal" (or "personal":true). "Personal" is a built-in pseudo-client GET /clients returns alongside the real ones.
  8. Respect LOCKED blocks. Every block tells you "locked": true | false. A locked block is one the user deliberately pinned — the API will REFUSE (HTTP 423) any attempt to move, resize, rename, re-tag, complete or delete it. You can never override that by just asking. If you genuinely need to change a locked block, first UNLOCK it as a separate, deliberate step you should confirm with the user — PATCH /timeblocking/blocks/{id} with { "locked": false } — then make your change, then re-lock with { "locked": true } if appropriate. When you plan a day, route AROUND locked blocks; treat them as immovable unless the user tells you to unlock.
  9. Stay inside the day’s WAKE→SLEEP window. GET /timeblocking/day and /days return "wake" and "sleep" (HH:MM, or null if not set) — the user’s wake-up and bedtime for that date, shown on the grid as blue lines. A sleep time AT or BEFORE wake (e.g. wake 07:00, sleep 01:00) means bedtime is 01:00 the NEXT morning — the waking day runs from wake, through midnight, to that time. /availability and /plan already keep their slots inside that band (slots past midnight carry "starts_next_day"/"ends_next_day" flags); when you place blocks yourself, never schedule before wake or after sleep.
  10. Blocks can CROSS MIDNIGHT: "end_time" ≤ "start_time" (e.g. 23:30→01:00) means the block runs into the next morning and belongs to its work_date’s evening. Such blocks return "crosses_midnight": true, and "duration_min" counts the full span. To create one, POST it on the evening’s work_date with the small-hours end time.
  11. Blocks also carry "recurring": true | false — true means it’s one occurrence of a repeating series the user set up in-app. Treat it like any other block when planning; there is no series endpoint in this API.
  12. Dates are YYYY-MM-DD, times are 24h HH:MM, timezone is Australia/Brisbane.
  13. Be idempotent when planning: read availability first, then only fill free slots — never double-book an occupied slot.
  14. Blocks NEVER overlap — the server enforces it. Every POST /timeblocking/blocks and every PATCH that moves/resizes a block is checked against the owner’s existing blocks (cross-midnight aware) and REFUSED with HTTP 409 plus the conflicting entries if the span is already taken. Place work into the free gaps GET /timeblocking/day returns, or use POST /timeblocking/plan to auto-place; if the user explicitly wants an occupied span, move or shrink the conflicting block(s) first.
  15. EVERY task you create needs a work_type, and which values are legal depends on the client. A REAL client takes one of deliverable | communication | sales | revisions | admin. Our own brands (agency / internal clients) and tasks with NO client take one of building | content | internal_admin. A PERSONAL block takes none at all — send "personal": true and no work_type. The two sets are never interchangeable: "building" on a client task is refused with HTTP 400, and so is "deliverable" on an internal one. Creating by title without a work_type is refused. If you are not sure which kind a client is, GET /clients and ask the user rather than guessing — a wrong type silently mis-buckets the work in every report that reads it.
  16. EVERY PIECE OF WORK BELONGS TO A TASK. Before doing agency or client work, find the open task it belongs to (GET /tasks, match on title, client and deliverable, not exact wording) and work inside a run on it. When nothing matches, create the task with POST /tasks — title, client_id, work_type, estimate_min, and "schedule": "now" to put it on the grid at the current time — then ask the person how long it will take and PATCH /tasks/{id} {"estimate_min": …} with the answer. Never do the work outside a task, and never make a second task for something that already has one.
  17. AGENT WORK LANDS IN THE TASK. When you are asked to work a task: GET /tasks (the owner’s open backlog) and GET /tasks/{id} (description, files, resources, sub-items, past runs) to read the brief, then do the work inside a RUN — POST /tasks/{id}/agent/runs to start it, POST /tasks/agent/runs/{runId}/events for progress lines ("kind":"log") and written answers ("kind":"reply"), POST /tasks/{id}/resources and /tasks/{id}/attachments to hand the work back, and PATCH /tasks/agent/runs/{runId} {"status":"done","summary":"…"} to close it. Everything appears live in the task’s Agent panel as you write it. Log honestly: what you read, what you decided, what you could not do.
  18. HAND THE WORK BACK AS A RESOURCE, NEVER A WALL OF TEXT. A resource is the finished thing, rendered inside the task. Three shapes: (1) a self-contained HTML page — a report, a comparison table, a review board, a client email draft to send later — POSTed to /tasks/{id}/resources as {"kind":"html","title":"…","html":"<!doctype html>…"}; (2) a PDF or image, POSTed to /tasks/{id}/attachments and then surfaced with {"kind":"pdf","attachment_id":"…"}; (3) a short written recap, {"kind":"text","text":"…"} or a reply event. An HTML resource renders in a preview card with Open full, Copy HTML and Download, so the person can take it straight out and use it.
  19. EVERY FILE OR RESOURCE YOU HAND BACK CARRIES local_path: the absolute path of the source file on the machine you built it on (the PDF you rendered, the .html you wrote, the image you generated). It is REQUIRED on POST /tasks/{id}/attachments and POST /tasks/{id}/resources (a "link" is the only kind that may omit it; a "pdf"/"image" resource inherits its attachment’s). The task shows the path and can Reveal it in Finder, so keep the file where you said it is after filing it — never file from a temp folder.
  20. HTML RESOURCES RENDER IN A SANDBOX. The markup is stored as text and only ever rendered inside an iframe with no scripts, no same-origin access and a strict CSP — it is never served as a page. So write PLAIN self-contained markup: inline styles (or one <style> block), table layout if it is an email, absolute https image URLs. A <script>, an external stylesheet, a fetch or a form submit will not run. That is also why an email draft built this way previews exactly as the client will see it.
  21. NOTES ARE FOR DURABLE CLIENT CONTEXT. A note is something whoever works on the client next needs to know: a decision, a preference, a standing fact about the client ("invoices go to accounts@ with the PO number in the subject", "never discount the hero product", "Lisa signs off, Tom only comments"). Save it with POST /notes {"client_id","title","body"} and it shows in the app’s Keep page under that client. Notes are NOT for task logs or run output — what happened on a job belongs on its task, as run events and resources. Notes are also the one place where READ is not global: a key whose owner is an admin reads every note, any other key reads only the notes it wrote, and only a note’s author can change or delete it.
  22. LEAD CRM: an agency’s own tables for tracking leads and prospects, edited like a sheet in the app. GET /crm/tables first, always — it returns every table’s columns (names, types, and, for a Status column, its choices) so you know exactly what to send. Add leads with POST /crm/tables/{id}/rows, keyed by COLUMN NAME, one row per lead, and give each lead its own external_id (e.g. "gplace:<place id>" or a form submission id) so re-running the same scrape or import never creates a duplicate — a repeat external_id comes back "duplicate", not a new row. NEVER invent a column: a key that matches nothing is skipped and listed in columns_unknown rather than guessed into the nearest existing one — POST /crm/tables/{id}/columns first (manager keys only: admin or agency access level). A batch import defaults quiet:true so the client is not emailed once per row; send quiet:false only when adding a single real-time lead.
  23. SOPs are the agency’s own library — how the business runs, written down once and reused: folders (services) hold SOPs, a SOP is either an uploaded PDF (kind:"pdf") or a formatted document (kind:"doc", body_html). READ by the whole team (admin, agency or team access level — a reviewer’s key is 403). WRITE (making/editing/deleting a folder, an SOP or a file) is manager keys only (admin or agency access level — a team key is 403, with a hint that SOPs are written by the owner or managers). GET /sops/folders first to see the tree (it’s flat — build it yourself on parent_id), then GET /sops?folder_id=<id> to list what’s filed in one ("root" = unfiled). kind can never change once an SOP is made — file a new one instead. An inline image inside body_html is referenced as <img src="sop-file:<file id>">, where <file id> is a file you uploaded to that SOP with role:"inline".

Quickstart — plan a day

  1. Get the free slots for the day. GET https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/availability?date=YYYY-MM-DD
  2. Get the open, unscheduled tasks to place (with estimates). GET https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/tasks?scheduled=false
  3. Optionally read existing blocks so you respect them. GET https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/day?date=YYYY-MM-DD
  4. Lay the chosen tasks into the free slots in one call (or POST blocks individually). POST https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/plan
  5. Re-read the day to confirm the plan landed. GET https://ulgzufjmnlxvhcgqoyzl.supabase.co/functions/v1/api/timeblocking/day?date=YYYY-MM-DD

Scopes

timeblocking:read
Read days, blocks, availability and tasks (everyone)
timeblocking:write
Create / move / delete blocks — OWN grid only
tasks:read
Read the backlog, a task’s full brief, files and resources, and every agent run + log (everyone)
tasks:write
Work a task as an agent: runs, log events, resources, deliverable files, status — OWN tasks only
clients:read
Client names, contacts and resources only
projects:read
Read projects and their tasks
chats:read
Read team chat messages
chats:write
Send messages + attachments (HTML/docs/images) into a client chat
social:read
Read the social / content pipeline
reviews:read
Read client-approval review batches, items and Meta copy templates
reviews:write
Create and edit review batches and their ads/emails (incl. media by URL)
notes:read
Read client notes (the Keep page). NOT read-all: an admin’s key reads every note, any other key reads only the notes it wrote. Issued to admin keys by default
notes:write
Save a note against a client, and edit or delete notes — OWN notes only (the key owner must be the author)
contracts:read
Read the agency's contracts (agreements and invoices), their status, timeline and signed copies. Manager keys only (an admin or an agency owner)
contracts:write
File a PDF as a contract for a client, send the signing link, void, mark paid. Manager keys only
crm:read
Read the Lead CRM: tables, their columns (names, types, select choices) and rows. A "managers" table only for a manager key
crm:write
Add / edit / delete rows on any table you can read. Creating, renaming or deleting a table or a column is manager keys only
sops:read
Read the agency’s SOP library: folders, SOPs (a document’s body_html, or a PDF’s signed link) and their files. Issued to admin, agency and team keys — never a reviewer
sops:write
Create, edit and delete folders, SOPs and their files. Manager keys only (admin or agency access level) — a team key is 403

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).

Clients

Names, contacts and resources ONLY. No status, financials, splits or attribution are ever returned.

GET /clients clients:read

All clients — id + name only. Includes the built-in { "id": "personal", "name": "Personal" } pseudo-client so you can tag non-client, personal-life blocks (send "client_id":"personal" on a block).

Example response

{ "clients": [ { "id": "personal", "name": "Personal" }, { "id": "uuid", "name": "Gala Equine" } ] }
GET /clients/{id} clients:read

One client: name, contacts and resources.

Example response

{ "id": "uuid", "name": "Gala Equine", "contacts": [ { "name": "Raj", "role": "Owner", "email": "raj@…", "phone": "…" } ], "resources": [ { "label": "Brand kit", "url": "https://…", "type": "drive" } ] }

Projects

Browse projects and the tasks inside them (no money fields).

GET /projects projects:read

All projects — id, name, client, status, projected end date.

Example response

{ "projects": [ { "id": "uuid", "name": "July Retainer", "client": "Gala Equine", "status": "active", "projected_end_date": "2026-07-31" } ] }
GET /projects/{id} projects:read

One project plus its tasks (titles, status, estimates).

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" } }

Social / Content Pipeline

See what content is in flight across the pipeline.

GET /social/pipeline social:read

Content pipeline items — stage/status, client, platform, due date.

Example response

{ "items": [ { "id": "uuid", "title": "Reel: behind the scenes", "client": "Cavalle & Co", "platform": "instagram", "stage": "in_review", "due": "2026-06-30" } ] }

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"
Copied