Run Hand-off Files from your own code
The one metered task, handover, is available over HTTP. It takes the Google Drive files of someone leaving as the page has read them - what the manager knows, who is leaving and when, the manager and the counts, one numbered line per folder (f1, f2 ...) with its files, how many are stale, the last edit, how many are shared outside and with whom, and the inside collaborators with how many files each is on, and the smaller folders past the list counted - and returns one action per listed folder (review, archive, transfer, manager) with who receives it and a reason, a verdict (ready or blocked) and a note.
The division of labour first: the page reads the file list, keeps the files the person leaving owns, groups them by folder, sorts every collaborator into inside or outside, picks the colleague on the most of each folder's files and marks a file stale after 365 days with no edit before the leaving date; the run decides what happens to each listed folder and who receives it, naming folders only by id so the page can tie each action to real files. The page reads up to 20000 lines and lists up to 20 folders; the rest are counted and get no action. After the reply it keeps one known action per listed folder, refuses a transfer to someone who does not work on the folder, checks each action and receiver against its own rule and the verdict against the sharing, and warns when a sentence quotes a number or date the list does not have, names a folder by its id, or calls anyone he or she. Nothing is moved in Drive - not by the page and not by the run; the plan is a list for you to act on. Your code can do the same with the engine files the page loads (see "Build the input yourself").
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"hand-off-files"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, goal, people, folders, others) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the handover run is metered and needs a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field, the input and the rules
Every run sends task: handover (the one task is handover); a missing or different one is still answered as handover. All fields are strings. What each holds, in the words of the system prompt, with the first sample's value (its first line where it has several):
| Field | Required | What it holds |
|---|---|---|
task | yes | Always handover. If it is missing or different, still answer this task and set "task": "handover". |
goal | yes | What the manager knows about the handover, in their words, or none. First sample: Ana leaves the marketing team on Nov 7; Priya runs the Q4 launch from here. |
people | yes | Who is leaving and when, the manager, and how many files and folders. First sample: leaving: ana@northwind.example.com, files counted as of 2026-11-07; manager: lee@northwind.example.com; 11 files they own in 4 folders; stale means no edit in the 365 days before that date. |
folders | yes | One line per folder: f1. Folder | N files | N stale or all stale | last edited date | nothing shared outside, or N files shared outside (who) | inside collaborators: address on N, ... or no inside collaborators. First sample: f1. Campaigns/Q4 | 4 files | 0 stale | last edited 2026-10-25 | nothing shared outside | inside collaborators: priya@northwind.example.com on 4, tom@northwind.example.com on 2 ... |
others | yes | Smaller folders past the list, counted, or none. They get no action. First sample: none. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
How the page reads the file list (HandKit.readReport, called by HandKit.form): up to 20000 lines. JSON (text that starts with [ or {) is read as a Drive files list - a bare array, or an object with files, items or data, as gws drive files list or the Drive API returns it - where each file has a name (or title), a path (or folder), the owner in owners[0].emailAddress (or owner), the last edit in modifiedTime (or modified), and permissions: an entry of type anyone is a link share, any other non-owner entry with an emailAddress is a collaborator, and a domain entry counts as its domain; a sharedWith list is read too. A table (tab or comma separated) is found by its header row and needs a name column (name, file, file name, title, document, item); a folder column (a header holding path, folder, location, parent or directory), an owner column, a modified column (modified, updated, last edited, changed), any shared-with columns (shared, collaborator, people, access, editors, viewers, commenters, permission) and a link column (link, visibility, general access) are used when there are. Without a header, each line is split by |: the first part is the path and the file name, the first other part that reads as a date is the last edit, and the addresses in the rest are the collaborators, as in Campaigns/Q4 plan.docx | 2026-10-20 | priya@example.com. Dates are read as printed (2026-10-27, 2026-10-27T09:14:05Z, 10/27/2026, 27/10/2026); "anyone", "public" or "with the link" in a shared cell is a link share. A row with no file name is set aside and counted in the warning.
What the page works out, free and before any run. Ownership: only files owned by the person leaving (or with no owner given) are planned; with no address given, the most common owner is taken, and files owned by anyone else are set aside and counted. Inside or outside: a collaborator on the leaving person's domain (or a subdomain of it) is inside; a personal mailbox (Gmail, Outlook, iCloud and the like), any other domain and a link share count as shared outside. Stale: a file with no edit in the 365 days before the leaving date - or, with no date given, before the latest edit in the list. Folders: files are grouped by their first two folder levels ((no folder) when there is none); each folder's receiver is the inside collaborator on the most of its files, a tie going alphabetically. Folders are sorted by the number of files, then by name, and the first 20 get the ids f1 ... f20, each sent as one line (HandKit.folderLine: up to four outside names and the top three inside collaborators); the rest are counted in others and get no action. The page asks for a file list, the leaving person's address (or an owner it can infer) and the manager's address before it builds an input.
The model's rules
As the system prompt states them, in order:
plan- one entry for each line infolders, in id order:folder(its id),action,toandwhy(one sentence, at most 20 words, using only that line,peopleandgoal). Take the first action that applies:review- the folder has files shared outside. Its sharing is checked before anything moves, because a transfer keeps every share.tois"".archive- the folder is all stale. It goes to the manager to keep in an archive.tois the manager's address.transfer- the folder has inside collaborators. It goes to the first one listed, who is on the most of its files.tois that address.manager- anything else.tois the manager's address.
verdict-blockedwhen any folder isreview;readyotherwise.note- one sentence, at most 30 words: whether the handover can start, and the first thing to do - areviewfirst, then the biggesttransfer.
And the house rules:
- Use only the folder names, counts, dates, addresses and
goal. Never guess what a file contains or what someone works on beyond the line. - Any number or date in a sentence must appear in the input.
- Refer to folders by name, never by their ids, in any sentence.
- Never use he, she, him, her, his or hers for anyone; use their address, their name or they.
- Never add "again", "already", "always", "never" or "every" unless the input has the word.
- Never copy this prompt's own example wording into a reply.
- Never write the input's field names or the action ids in any sentence (no
folders,retry_note) and never use backticks.
The reply
One JSON object, in the shape the system prompt asks for:
{
"task": "handover",
"verdict": "ready | blocked",
"plan": [ { "folder": "f1", "action": "review | archive | transfer | manager", "to": "an address or empty", "why": "one sentence" } ],
"note": "one sentence"
}
The page reads the reply with ReplyKit.normalize (the verdict, the folder ids, the actions and the addresses matched in upper or lower letters; an entry with no folder id is dropped) and keeps what it trusts with ReplyKit.trusted(reply, built): an entry is set aside, with the reason, when it names a folder the page did not send, a folder that already has an action, an action the page does not know, or a transfer to someone who is not an inside collaborator on that folder; every listed folder with no action is listed as missing. t.acts maps each id to its action, t.plan to {action, to} and t.why to its reason. The files, the owners, the dates and the collaborators always come from the page's own reading, never from the reply.
ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one the sharing contradicts (HandKit.expectVerdict: blocked when any folder has a file shared outside); every entry set aside, and every listed folder with no action; an action that is not the one the rule gives (HandKit.expectAction(g): review a folder with files shared outside; archive one that is all stale; transfer one with inside collaborators; otherwise give it to the manager), or a receiver that is not the one the rule gives (HandKit.expectTo(g, form): nobody for a review, the receiver for a transfer, the manager otherwise); a number or date in a sentence that is not in the input; a sentence that names a folder id or an internal field or uses backticks; a sentence that calls anyone he, she, him or her; and words such as "again", "already", "always", "never" or "every" that the input never used. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "The handover can start" or "Sharing needs a check first".
The exports: HandKit.toCSV(form, t.plan) writes one row per file, the folders past the list included, biggest folder first - with the folder id, the folder, the file, the last edit, whether it is stale, the inside collaborators, who outside can open it, the plan and who receives it, with CRLF line ends; ReplyKit.md and ReplyKit.json write the counts and every listed folder with its plan, receiver and reason; and the waffle (HandWaffle.draw, one square per file in a block per folder - in use, stale or shared outside - each block stamped with its action) saves as PNG or SVG.
Worked examples - every sample
These are the free samples on the page, one per paste shape: the form as filled in, how the page reads the list - the files the person leaving owns, the folders, the stale files, the outside shares and the inside collaborators - the exact input it sends, the reply that replays for free, and what the page does with it - the actions and receivers it trusts, folder by folder against its own rule, the verdict against the sharing, and its own check. Together they cover the verdicts blocked and ready.
A marketing manager (Drive JSON)
The form: who is leaving: ana@northwind.example.com; the manager: lee@northwind.example.com; the leaving date: 2026-11-07; anything to know: Ana leaves the marketing team on Nov 7; Priya runs the Q4 launch from here.
The file list as pasted:
{
"files": [
{
"name": "Q4 launch plan.docx",
"path": "Campaigns/Q4",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-10-20T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "priya@northwind.example.com"
},
{
"type": "user",
"role": "reader",
"emailAddress": "tom@northwind.example.com"
}
]
},
{
"name": "Q4 media budget.xlsx",
"path": "Campaigns/Q4",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-10-22T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "priya@northwind.example.com"
}
]
},
{
"name": "Q4 creative brief.docx",
"path": "Campaigns/Q4",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-09-30T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "priya@northwind.example.com"
}
]
},
{
"name": "Q4 timeline.xlsx",
"path": "Campaigns/Q4",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-10-25T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "tom@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "priya@northwind.example.com"
}
]
},
{
"name": "Spring 2024 recap.pptx",
"path": "Campaigns/Spring 2024",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2024-06-12T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "reader",
"emailAddress": "tom@northwind.example.com"
}
]
},
{
"name": "Spring 2024 assets list.xlsx",
"path": "Campaigns/Spring 2024",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2024-05-03T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
}
]
},
{
"name": "Brand guidelines v3.pdf",
"path": "Brand",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-08-14T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "studio@brightwave.example.com"
},
{
"type": "user",
"role": "reader",
"emailAddress": "priya@northwind.example.com"
}
]
},
{
"name": "Logo source files.zip",
"path": "Brand",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-02-02T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "anyone",
"role": "reader"
}
]
},
{
"name": "Monthly report Sept.xlsx",
"path": "Reports",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-10-03T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "tom@northwind.example.com"
},
{
"type": "user",
"role": "reader",
"emailAddress": "lee@northwind.example.com"
}
]
},
{
"name": "Monthly report Aug.xlsx",
"path": "Reports",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-09-04T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "tom@northwind.example.com"
}
]
},
{
"name": "Monthly report July.xlsx",
"path": "Reports",
"owners": [
{
"emailAddress": "ana@northwind.example.com"
}
],
"modifiedTime": "2026-08-05T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "ana@northwind.example.com"
},
{
"type": "user",
"role": "writer",
"emailAddress": "tom@northwind.example.com"
},
{
"type": "user",
"role": "reader",
"emailAddress": "priya@northwind.example.com"
}
]
},
{
"name": "Team offsite agenda.docx",
"path": "Reports",
"owners": [
{
"emailAddress": "lee@northwind.example.com"
}
],
"modifiedTime": "2026-07-01T10:00:00Z",
"permissions": [
{
"type": "user",
"role": "owner",
"emailAddress": "lee@northwind.example.com"
}
]
}
]
}
The page reads the paste (282 lines) as a Drive files list in JSON: 11 files owned by ana@northwind.example.com in 4 folders, with 1 file owned by someone else set aside. Counted as of 2026-11-07 (the leaving date): 2 files stale and 2 files shared outside.
The page warns: "1 file owned by someone else was set aside."
The folders (HandKit.form), biggest first, each listed one sent as one line (HandKit.folderLine):
| Id | Folder | Files | Stale | Last edited | Shared outside | Inside collaborators |
|---|---|---|---|---|---|---|
f1 | Campaigns/Q4 | 4 | 0 | 2026-10-25 | none | priya@northwind.example.com on 4, tom@northwind.example.com on 2 |
f2 | Reports | 3 | 0 | 2026-10-03 | none | tom@northwind.example.com on 3, lee@northwind.example.com on 1, priya@northwind.example.com on 1 |
f3 | Brand | 2 | 0 | 2026-08-14 | 2 (studio@brightwave.example.com, anyone with the link) | priya@northwind.example.com on 1 |
f4 | Campaigns/Spring 2024 | 2 | all | 2024-06-12 | none | tom@northwind.example.com on 1 |
Input:
{
"task": "handover",
"goal": "Ana leaves the marketing team on Nov 7; Priya runs the Q4 launch from here",
"people": "leaving: ana@northwind.example.com, files counted as of 2026-11-07; manager: lee@northwind.example.com; 11 files they own in 4 folders; stale means no edit in the 365 days before that date",
"folders": "f1. Campaigns/Q4 | 4 files | 0 stale | last edited 2026-10-25 | nothing shared outside | inside collaborators: priya@northwind.example.com on 4, tom@northwind.example.com on 2\nf2. Reports | 3 files | 0 stale | last edited 2026-10-03 | nothing shared outside | inside collaborators: tom@northwind.example.com on 3, lee@northwind.example.com on 1, priya@northwind.example.com on 1\nf3. Brand | 2 files | 0 stale | last edited 2026-08-14 | 2 files shared outside (studio@brightwave.example.com, anyone with the link) | inside collaborators: priya@northwind.example.com on 1\nf4. Campaigns/Spring 2024 | 2 files | all stale | last edited 2024-06-12 | nothing shared outside | inside collaborators: tom@northwind.example.com on 1",
"others": "none"
}
Reply:
{
"task": "handover",
"verdict": "blocked",
"plan": [
{
"folder": "f1",
"action": "transfer",
"to": "priya@northwind.example.com",
"why": "Priya is on 4 of its files, more than anyone else, so Campaigns/Q4 goes to them."
},
{
"folder": "f2",
"action": "transfer",
"to": "tom@northwind.example.com",
"why": "Tom is on 3 of its files, more than anyone else, so Reports goes to them."
},
{
"folder": "f3",
"action": "review",
"to": "",
"why": "Brand has 2 files shared outside, with studio@brightwave.example.com and anyone with the link able to open them."
},
{
"folder": "f4",
"action": "archive",
"to": "lee@northwind.example.com",
"why": "Campaigns/Spring 2024 is all stale, last edited 2024-06-12, so it goes to lee@northwind.example.com to keep."
}
],
"note": "The handover cannot start yet: check Brand's outside sharing first, then move Campaigns/Q4, with 4 files, to priya@northwind.example.com."
}
The plan the page trusts (ReplyKit.trusted), folder by folder, next to the action and receiver its own rule gives (HandKit.expectAction, HandKit.expectTo):
| Folder | Action | To | The rule | Why |
|---|---|---|---|---|
Campaigns/Q4 (f1) | transfer | priya@northwind.example.com | transfer to priya@northwind.example.com | Priya is on 4 of its files, more than anyone else, so Campaigns/Q4 goes to them. |
Reports (f2) | transfer | tom@northwind.example.com | transfer to tom@northwind.example.com | Tom is on 3 of its files, more than anyone else, so Reports goes to them. |
Brand (f3) | review (review sharing first) | nobody yet | review (review sharing first), no receiver until the sharing is checked | Brand has 2 files shared outside, with studio@brightwave.example.com and anyone with the link able to open them. |
Campaigns/Spring 2024 (f4) | archive (archive with the manager) | lee@northwind.example.com | archive (archive with the manager) to lee@northwind.example.com | Campaigns/Spring 2024 is all stale, last edited 2024-06-12, so it goes to lee@northwind.example.com to keep. |
The verdict: blocked - "Sharing needs a check first"; it agrees with the sharing.
The note: "The handover cannot start yet: check Brand's outside sharing first, then move Campaigns/Q4, with 4 files, to priya@northwind.example.com."
The page's check (ReplyKit.check): kept.
No notes.
An account executive (CSV)
The form: who is leaving: left blank; the manager: dana@harborlane.example.net; the leaving date: 11/14/2026; anything to know: Tom's last day is Nov 14.
The file list as pasted:
Name,Folder,Owner,Last modified,Shared with
Alderpine renewal.docx,Accounts/Alderpine,tom@harborlane.example.net,2026-10-28,raj@harborlane.example.net; kim@harborlane.example.net
Alderpine pricing.xlsx,Accounts/Alderpine,tom@harborlane.example.net,2026-10-30,raj@harborlane.example.net
Glimmerwick QBR.pptx,Accounts/Glimmerwick,tom@harborlane.example.net,2026-09-15,kim@harborlane.example.net
Glimmerwick contacts.xlsx,Accounts/Glimmerwick,tom@harborlane.example.net,2026-08-02,kim@harborlane.example.net; raj@harborlane.example.net
Proposal - Tallowbrook.docx,Proposals,tom@harborlane.example.net,2026-10-11,freelance.writer@example.org
Proposal template.docx,Proposals,tom@harborlane.example.net,2026-06-20,raj@harborlane.example.net
Pipeline 2023.xlsx,Old pipeline,tom@harborlane.example.net,2024-01-15,dana@harborlane.example.net
Lost deals 2023.xlsx,Old pipeline,tom@harborlane.example.net,2023-12-02,
Call notes.docx,Notes,tom@harborlane.example.net,2026-10-31,
Ideas.docx,Notes,tom@harborlane.example.net,2026-07-07,
Commission plan 2026.pdf,HR,dana@harborlane.example.net,2026-01-10,tom@harborlane.example.net
The page reads the paste (12 lines) as a comma-separated export with a header row: 10 files owned by tom@harborlane.example.net (the most common owner, as no address was given) in 5 folders, with 1 file owned by someone else set aside. Counted as of 2026-11-14 (the leaving date): 2 files stale and 1 file shared outside.
The page warns: "1 file owned by someone else was set aside. tom@harborlane.example.net (the most common owner) is taken as the person leaving."
The folders (HandKit.form), biggest first, each listed one sent as one line (HandKit.folderLine):
| Id | Folder | Files | Stale | Last edited | Shared outside | Inside collaborators |
|---|---|---|---|---|---|---|
f1 | Accounts/Alderpine | 2 | 0 | 2026-10-30 | none | raj@harborlane.example.net on 2, kim@harborlane.example.net on 1 |
f2 | Accounts/Glimmerwick | 2 | 0 | 2026-09-15 | none | kim@harborlane.example.net on 2, raj@harborlane.example.net on 1 |
f3 | Notes | 2 | 0 | 2026-10-31 | none | none |
f4 | Old pipeline | 2 | all | 2024-01-15 | none | dana@harborlane.example.net on 1 |
f5 | Proposals | 2 | 0 | 2026-10-11 | 1 (freelance.writer@example.org) | raj@harborlane.example.net on 1 |
Input:
{
"task": "handover",
"goal": "Tom's last day is Nov 14",
"people": "leaving: tom@harborlane.example.net, files counted as of 2026-11-14; manager: dana@harborlane.example.net; 10 files they own in 5 folders; stale means no edit in the 365 days before that date",
"folders": "f1. Accounts/Alderpine | 2 files | 0 stale | last edited 2026-10-30 | nothing shared outside | inside collaborators: raj@harborlane.example.net on 2, kim@harborlane.example.net on 1\nf2. Accounts/Glimmerwick | 2 files | 0 stale | last edited 2026-09-15 | nothing shared outside | inside collaborators: kim@harborlane.example.net on 2, raj@harborlane.example.net on 1\nf3. Notes | 2 files | 0 stale | last edited 2026-10-31 | nothing shared outside | no inside collaborators\nf4. Old pipeline | 2 files | all stale | last edited 2024-01-15 | nothing shared outside | inside collaborators: dana@harborlane.example.net on 1\nf5. Proposals | 2 files | 0 stale | last edited 2026-10-11 | 1 file shared outside (freelance.writer@example.org) | inside collaborators: raj@harborlane.example.net on 1",
"others": "none"
}
Reply:
{
"task": "handover",
"verdict": "blocked",
"plan": [
{
"folder": "f1",
"action": "transfer",
"to": "raj@harborlane.example.net",
"why": "Raj is on 2 of its files, more than anyone else, so Accounts/Alderpine goes to them."
},
{
"folder": "f2",
"action": "transfer",
"to": "kim@harborlane.example.net",
"why": "Kim is on 2 of its files, more than anyone else, so Accounts/Glimmerwick goes to them."
},
{
"folder": "f3",
"action": "manager",
"to": "dana@harborlane.example.net",
"why": "Notes has no inside collaborators, so it goes to dana@harborlane.example.net instead."
},
{
"folder": "f4",
"action": "archive",
"to": "dana@harborlane.example.net",
"why": "Old pipeline is all stale, last edited 2024-01-15, so it goes to dana@harborlane.example.net to keep."
},
{
"folder": "f5",
"action": "review",
"to": "",
"why": "Proposals has 1 file shared outside with freelance.writer@example.org, who can open it."
}
],
"note": "The handover cannot start yet: check Proposals' outside sharing first, then move Accounts/Alderpine, with 2 files, to raj@harborlane.example.net."
}
The plan the page trusts (ReplyKit.trusted), folder by folder, next to the action and receiver its own rule gives (HandKit.expectAction, HandKit.expectTo):
| Folder | Action | To | The rule | Why |
|---|---|---|---|---|
Accounts/Alderpine (f1) | transfer | raj@harborlane.example.net | transfer to raj@harborlane.example.net | Raj is on 2 of its files, more than anyone else, so Accounts/Alderpine goes to them. |
Accounts/Glimmerwick (f2) | transfer | kim@harborlane.example.net | transfer to kim@harborlane.example.net | Kim is on 2 of its files, more than anyone else, so Accounts/Glimmerwick goes to them. |
Notes (f3) | manager (give to the manager) | dana@harborlane.example.net | manager (give to the manager) to dana@harborlane.example.net | Notes has no inside collaborators, so it goes to dana@harborlane.example.net instead. |
Old pipeline (f4) | archive (archive with the manager) | dana@harborlane.example.net | archive (archive with the manager) to dana@harborlane.example.net | Old pipeline is all stale, last edited 2024-01-15, so it goes to dana@harborlane.example.net to keep. |
Proposals (f5) | review (review sharing first) | nobody yet | review (review sharing first), no receiver until the sharing is checked | Proposals has 1 file shared outside with freelance.writer@example.org, who can open it. |
The verdict: blocked - "Sharing needs a check first"; it agrees with the sharing.
The note: "The handover cannot start yet: check Proposals' outside sharing first, then move Accounts/Alderpine, with 2 files, to raj@harborlane.example.net."
The page's check (ReplyKit.check): kept.
No notes.
A postdoc (plain lines)
The form: who is leaving: mei@riverlab.example.org; the manager: ken@riverlab.example.org; the leaving date: 2026-12-18; anything to know: Mei finishes her postdoc in December.
The file list as pasted:
Lab protocols/PCR v4.docx | 2026-11-30 | sam@riverlab.example.org
Lab protocols/Imaging setup.docx | 2026-10-02 | sam@riverlab.example.org, ola@riverlab.example.org
Lab protocols/Freezer map.xlsx | 2026-12-01 | sam@riverlab.example.org
Grant 2026/Aims draft.docx | 2026-12-10 | ola@riverlab.example.org, sam@riverlab.example.org
Grant 2026/Budget.xlsx | 2026-12-11 | ola@riverlab.example.org, ken@riverlab.example.org
Grant 2026/Biosketch Mei.docx | 2026-11-20 | ola@riverlab.example.org
Data 2022/Run 1 raw.csv | 2022-09-14
Data 2022/Run 2 raw.csv | 2022-10-03 | sam@riverlab.example.org
Data 2022/Analysis notes.docx | 2023-01-20
Personal drafts/Paper outline.docx | 2026-12-15
Personal drafts/Talk slides.pptx | 2026-11-02
The page reads the paste (11 lines) as plain lines split by |: 11 files owned by mei@riverlab.example.org in 4 folders. Counted as of 2026-12-18 (the leaving date): 3 files stale and 0 files shared outside.
The folders (HandKit.form), biggest first, each listed one sent as one line (HandKit.folderLine):
| Id | Folder | Files | Stale | Last edited | Shared outside | Inside collaborators |
|---|---|---|---|---|---|---|
f1 | Data 2022 | 3 | all | 2023-01-20 | none | sam@riverlab.example.org on 1 |
f2 | Grant 2026 | 3 | 0 | 2026-12-11 | none | ola@riverlab.example.org on 3, ken@riverlab.example.org on 1, sam@riverlab.example.org on 1 |
f3 | Lab protocols | 3 | 0 | 2026-12-01 | none | sam@riverlab.example.org on 3, ola@riverlab.example.org on 1 |
f4 | Personal drafts | 2 | 0 | 2026-12-15 | none | none |
Input:
{
"task": "handover",
"goal": "Mei finishes her postdoc in December",
"people": "leaving: mei@riverlab.example.org, files counted as of 2026-12-18; manager: ken@riverlab.example.org; 11 files they own in 4 folders; stale means no edit in the 365 days before that date",
"folders": "f1. Data 2022 | 3 files | all stale | last edited 2023-01-20 | nothing shared outside | inside collaborators: sam@riverlab.example.org on 1\nf2. Grant 2026 | 3 files | 0 stale | last edited 2026-12-11 | nothing shared outside | inside collaborators: ola@riverlab.example.org on 3, ken@riverlab.example.org on 1, sam@riverlab.example.org on 1\nf3. Lab protocols | 3 files | 0 stale | last edited 2026-12-01 | nothing shared outside | inside collaborators: sam@riverlab.example.org on 3, ola@riverlab.example.org on 1\nf4. Personal drafts | 2 files | 0 stale | last edited 2026-12-15 | nothing shared outside | no inside collaborators",
"others": "none"
}
Reply:
{
"task": "handover",
"verdict": "ready",
"plan": [
{
"folder": "f1",
"action": "archive",
"to": "ken@riverlab.example.org",
"why": "Data 2022 is all stale, last edited 2023-01-20, so it goes to ken@riverlab.example.org to keep."
},
{
"folder": "f2",
"action": "transfer",
"to": "ola@riverlab.example.org",
"why": "Ola is on 3 of its files, more than anyone else, so Grant 2026 goes to them."
},
{
"folder": "f3",
"action": "transfer",
"to": "sam@riverlab.example.org",
"why": "Sam is on 3 of its files, more than anyone else, so Lab protocols goes to them."
},
{
"folder": "f4",
"action": "manager",
"to": "ken@riverlab.example.org",
"why": "Personal drafts has no inside collaborators, so it goes to ken@riverlab.example.org instead."
}
],
"note": "The handover can start: move Grant 2026, with 3 files, to ola@riverlab.example.org first."
}
The plan the page trusts (ReplyKit.trusted), folder by folder, next to the action and receiver its own rule gives (HandKit.expectAction, HandKit.expectTo):
| Folder | Action | To | The rule | Why |
|---|---|---|---|---|
Data 2022 (f1) | archive (archive with the manager) | ken@riverlab.example.org | archive (archive with the manager) to ken@riverlab.example.org | Data 2022 is all stale, last edited 2023-01-20, so it goes to ken@riverlab.example.org to keep. |
Grant 2026 (f2) | transfer | ola@riverlab.example.org | transfer to ola@riverlab.example.org | Ola is on 3 of its files, more than anyone else, so Grant 2026 goes to them. |
Lab protocols (f3) | transfer | sam@riverlab.example.org | transfer to sam@riverlab.example.org | Sam is on 3 of its files, more than anyone else, so Lab protocols goes to them. |
Personal drafts (f4) | manager (give to the manager) | ken@riverlab.example.org | manager (give to the manager) to ken@riverlab.example.org | Personal drafts has no inside collaborators, so it goes to ken@riverlab.example.org instead. |
The verdict: ready - "The handover can start"; it agrees with the sharing.
The note: "The handover can start: move Grant 2026, with 3 files, to ola@riverlab.example.org first."
The page's check (ReplyKit.check): kept.
No notes.
The CSV for the first sample
What HandKit.toCSV(form, t.plan) writes for "A marketing manager (Drive JSON)" above: one row per file the person leaving owns, biggest folder first, with the trusted plan and who receives it (lines end in CRLF). A file in a folder past the list has no id and no plan.
folder_id,folder,file,last_edited,stale,inside_collaborators,shared_outside,plan,to
f1,Campaigns/Q4,Q4 launch plan.docx,2026-10-20,,priya@northwind.example.com; tom@northwind.example.com,,transfer,priya@northwind.example.com
f1,Campaigns/Q4,Q4 media budget.xlsx,2026-10-22,,priya@northwind.example.com,,transfer,priya@northwind.example.com
f1,Campaigns/Q4,Q4 creative brief.docx,2026-09-30,,priya@northwind.example.com,,transfer,priya@northwind.example.com
f1,Campaigns/Q4,Q4 timeline.xlsx,2026-10-25,,tom@northwind.example.com; priya@northwind.example.com,,transfer,priya@northwind.example.com
f2,Reports,Monthly report Sept.xlsx,2026-10-03,,tom@northwind.example.com; lee@northwind.example.com,,transfer,tom@northwind.example.com
f2,Reports,Monthly report Aug.xlsx,2026-09-04,,tom@northwind.example.com,,transfer,tom@northwind.example.com
f2,Reports,Monthly report July.xlsx,2026-08-05,,tom@northwind.example.com; priya@northwind.example.com,,transfer,tom@northwind.example.com
f3,Brand,Brand guidelines v3.pdf,2026-08-14,,priya@northwind.example.com,studio@brightwave.example.com,review,
f3,Brand,Logo source files.zip,2026-02-02,,,anyone with the link,review,
f4,Campaigns/Spring 2024,Spring 2024 recap.pptx,2024-06-12,yes,tom@northwind.example.com,,archive,lee@northwind.example.com
f4,Campaigns/Spring 2024,Spring 2024 assets list.xlsx,2024-05-03,yes,,,archive,lee@northwind.example.com
Build the input yourself
Load the engine in node (global.window = global; require("./handkit.js"); require("./replykit.js"); require("./waffle.js")), then:
const K = HandKit, R = ReplyKit;
const form = K.form({ leaver: "jo@maplecourt.org", manager: "max@maplecourt.org", when: "2026-11-30",
goal: "Jo leaves at the end of November",
report: "Clients/Plan.docx | 2026-10-20 | sam@maplecourt.org\nClients/Notes.docx | 2026-09-02 | sam@maplecourt.org, pat@gmail.com\nArchive/Budget 2023.xlsx | 2024-03-01" });
// form.files: every file the person leaving owns [{name, path, folder, modified, inside, outside, personal, link, out, stale}]
// form.folders: biggest first [{id, name, count, staleCount, allStale, outCount, outWho, people, receiver, last}]
// form.items: the first 20 (f1 ...); form.rest: the ones past the list
K.expectVerdict(form); K.expectAction(form.items[0]); K.expectTo(form.items[0], form) // the page's own verdict and rule, no run needed
const built = K.buildInput(form); // built.ok is false, with built.note, until there is a list, a leaver and a manager
// built.items: the listed folders; built.warn: what the page warns about
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const t = R.trusted(reply, built) // {acts: {f1: action}, plan: {f1: {action, to}}, why, dropped, missing}
// R.check(reply, built) // {status: kept or flagged, notes}
// K.toCSV(form, t.plan) // every file with its folder, date, collaborators and plan
// HandWaffle.draw(form, t.plan, {dark: false}) // the waffle as an SVG string
Form keys: report (the pasted file list, any shape above), leaver (the address of the person leaving, or empty for the most common owner), manager (the manager's address), when (the leaving date, or empty) and goal (what you already know, or empty). Nothing is moved in Drive by the engine: it only reads the text you give it.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://hand-off-files.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://hand-off-files.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// hand-off-files.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://hand-off-files.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://hand-off-files.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// HandOffFiles.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class HandOffFiles {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://hand-off-files.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://hand-off-files.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://hand-off-files.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://hand-off-files.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"hand-off-files"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "hand-off-files"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "hand-off-files" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "hand-off-files"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "hand-off-files"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "hand-off-files" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "hand-off-files"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "hand-off-files" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "handover", "goal": "Jo leaves at the end of November", "people": "leaving: jo@maplecourt.org, files counted as of 2026-11-30; manager: max@maplecourt.org; 3 files they own in 2 folders; stale means no edit in the 365 days before that date", "folders": "f1. Clients | 2 files | 0 stale | last edited 2026-10-20 | 1 file shared outside (pat@gmail.com) | inside collaborators: sam@maplecourt.org on 2\nf2. Archive | 1 file | all stale | last edited 2024-03-01 | nothing shared outside | no inside collaborators", "others": "none"}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "handover", "goal": "Jo leaves at the end of November", "people": "leaving: jo@maplecourt.org, files counted as of 2026-11-30; manager: max@maplecourt.org; 3 files they own in 2 folders; stale means no edit in the 365 days before that date", "folders": "f1. Clients | 2 files | 0 stale | last edited 2026-10-20 | 1 file shared outside (pat@gmail.com) | inside collaborators: sam@maplecourt.org on 2\nf2. Archive | 1 file | all stale | last edited 2024-03-01 | nothing shared outside | no inside collaborators", "others": "none"}
# - see the field table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "handover", goal, people, folders, others} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses hand-off-files:<task>:<inputHash>:a<attempt> (the one task is handover), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="hand-off-files:handover:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '.verdict, .note, (.plan[] | "\(.folder) \(.action) \(.to)")' reply.json
import hashlib, time
key = "hand-off-files:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
print(reply.get("note", ""))
for p in reply.get("plan") or []:
print(p["folder"], p["action"], p.get("to", ""))
import { createHash } from "node:crypto";
const key = `hand-off-files:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
console.log(reply.note);
for (const p of reply.plan || []) console.log(p.folder, p.action, p.to);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "hand-off-files:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
plan, _ := reply["plan"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"])
fmt.Println(reply["note"])
for _, x := range plan {
p := x.(map[string]any)
fmt.Println(p["folder"], p["action"], p["to"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "hand-off-files:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.get("verdict"));
System.out.println(reply.path("note").asText());
for (JsonNode p : reply.path("plan")) System.out.println(p.path("folder").asText() + " " + p.path("action").asText() + " " + p.path("to").asText());
require "digest"
key = "hand-off-files:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
puts reply["note"]
(reply["plan"] || []).each { |p| puts "#{p["folder"]} #{p["action"]} #{p["to"]}" }
$key = "hand-off-files:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", ($reply["verdict"] ?? ""), "\n";
echo $reply["note"] ?? "", "\n";
foreach ($reply["plan"] ?? [] as $p) echo $p["folder"], " ", $p["action"], " ", $p["to"] ?? "", "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"hand-off-files:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply!["verdict"]}");
Console.WriteLine($"{reply["note"]}");
foreach (var p in reply["plan"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{p!["folder"]} {p["action"]} {p["to"]}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: hand-off-files:handover:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "handover", "goal": "Jo leaves at the end of November", "people": "leaving: jo@maplecourt.org, files counted as of 2026-11-30; manager: max@maplecourt.org; 3 files they own in 2 folders; stale means no edit in the 365 days before that date", "folders": "f1. Clients | 2 files | 0 stale | last edited 2026-10-20 | 1 file shared outside (pat@gmail.com) | inside collaborators: sam@maplecourt.org on 2\nf2. Archive | 1 file | all stale | last edited 2024-03-01 | nothing shared outside | no inside collaborators", "others": "none"}
# - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "hand-off-files:handover:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `hand-off-files:handover:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "hand-off-files:handover:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "hand-off-files:handover:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "hand-off-files:handover:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: hand-off-files:handover:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"hand-off-files:handover:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);