Run Slice Desk from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the spec - it numbers the requirements itself (R1, R2 ...) with their sections and lines, sets aside what is out of scope or still open, notes vague spots, and spots a wide refactor or a spec written by layer - and checks any set of tickets as a dependency graph: cycles, unknown blockers, numbering, the frontier, the waves, the critical path, one-layer tickets and coverage, then hands it all to the run as facts. The run writes the tickets or the revision; the page then holds the reply to the same graph check (slicereply.js).
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":"slice-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. 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 key is missing - all six are required. The page always sends all six of task, feature, spec, tickets, feedback, facts; tickets and feedback are plain sentences in the slice lane. |
| 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: both lanes are metered and need 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 comes first
Two lanes, chosen by task, in the order the work runs - slice, then revise until the breakdown is approved:
slice- tracer-bullet tickets: narrow vertical slices (or prefactors, or the expand - migrate - contract sequence of a wide refactor), each with its blocking edges, acceptance criteria and the requirement ids it covers; the requirements left out on purpose; and the skill's three questions (granularity, edges, merge-split) about the breakdown.revise- the full revised set of tickets on the user's feedback, with a change log (merged, split, re-edged, renamed, added, removed, rescoped), each change tied to the feedback it answers.
A missing or unknown task is answered as slice. Both lanes send the same six fields. The facts are the page's reading - the requirements with their ids, sections and lines, out of scope, open questions, vague spots, the wide-refactor signal and whether the spec is written by layer, and in the revise lane the current tickets' graph and the page's check of them - and a line starting "must:" is a rule the reply has to hold. The page's handoff puts the slice's tickets in tickets for the revise lane. A spec longer than 20,000 characters is cut on whole lines: every heading and every line a requirement came from goes whole, other prose is cut and marked, and every requirement is still in the facts.
The input
| Field | Required | What |
|---|---|---|
task | yes | slice or revise. If it is missing or neither, do slice and set "task": "slice". |
feature | yes | the feature's name and the size of the spec. |
spec | yes | the plan or spec as written. A line starting "[... N lines" and ending "...]" marks prose the page read but did not send; every requirement is still in the facts. |
tickets | yes (a sentence when none) | revise task: the current tickets in the local ticket template (a "# NN: Title" heading, What to build, Blocked by, Status, Covers, Kind, checkboxes), one file each; otherwise a sentence saying there are none yet. |
feedback | yes (a sentence when none) | revise task: what the user wants changed, in their words; otherwise a sentence saying this is the first breakdown. |
facts | yes | the page's reading: the requirements (id | section | line | text), what is out of scope, the open questions, the vague spots, the wide-refactor signal and whether the spec is written by layer; in the revise task also the current tickets' graph (waves, critical path, frontier) and the page's check of them (id | rule | severity | tickets | evidence | the page's value). A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @mattpocock/to-tickets - the only standard the run applies:
- Each ticket is a tracer bullet: a narrow but complete path through every layer (schema, API, UI, tests) - vertical, never a horizontal slice of one layer.
- A completed slice is demoable or verifiable on its own.
- Each slice is sized to fit in a single fresh context window.
- Any prefactoring is done first: make the change easy, then make the easy change. A prefactor ticket comes before the slices it makes easy, and they are blocked by it.
- Every ticket declares its blocking edges: the tickets that must complete before it can start. A ticket with no blockers can start immediately. Each ticket depends only on tickets that genuinely gate it.
- Wide refactors are the exception to vertical slicing. A wide refactor is one mechanical change (rename a column, retype a shared symbol) whose blast radius fans across the whole codebase, so no vertical slice can land green. Sequence it as expand - contract: first expand (add the new form beside the old, so nothing breaks); then migrate the call sites in batches sized by blast radius (per package, per directory), each batch its own ticket blocked by the expand, CI green batch to batch because the old form still exists; finally contract (delete the old form once no caller remains), in a ticket blocked by every migrate batch. When even the batches cannot stay green alone, keep the sequence but put them on a shared integration branch that all block a final integrate-and-verify ticket; green is promised only there.
- Tickets are numbered from 01 in dependency order, blockers first. Work the frontier: any ticket whose blockers are all done can start.
- The ticket's What to build is the end-to-end behaviour it makes work, from the user's perspective - not a layer-by-layer implementation list. Acceptance criteria are checkable statements.
- Avoid specific file paths and code snippets: they go stale fast. The one exception is a snippet from a prototype that encodes a decision more precisely than prose (a state machine, a reducer, a schema, a type shape) - trimmed to the decision-rich part and marked as coming from a prototype.
- Before the tickets go to agents, the user is asked: does the granularity feel right (too coarse, too fine)? Are the blocking edges correct - does each ticket depend only on tickets that genuinely gate it? Should any tickets be merged or split further? Iterate until the user approves.
House rules
- Use only the input. Never invent a requirement, a product feature, a team, a tool or a constraint the spec does not give. Name things in the spec's own words.
- The page's requirement ids, sections and counts are facts: cite requirements by the page's ids, never renumber them.
- Never ticket anything the facts list as out of scope. An open question or a vague spot that blocks a ticket goes in that ticket's criteria as a decision to make, or in caveats.
- Write about the work and the people who use it. Never mention the request's plumbing - not its field names, not that something was or was not sent.
- Never name a file path, a tracker, a setup step or a folder: the page exports the tickets itself.
- No web links (http or https) in prose.
- Write in English. Keep the JSON keys and the ids exactly as given.
- Never use backticks in prose (a decision-rich snippet from a prototype, when the spec carries one, may keep its code formatting). Words and terms of art from the rules above are free to use; what you never do is paste a whole sentence or quoted saying from this prompt, or state a rule with no fact of this spec in it.
- Never write a gendered pronoun for a person - not he, she, his or her; write they, their, the user, the admin or the team.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The ticket shape (both lanes)
number- "01", "02", ... in dependency order, blockers first, no gaps.title- a short descriptive name in the spec's vocabulary. A prefactor's title starts "Prefactor:", an expand's "Expand:", a contract's "Contract:".kind- one of: slice (a vertical slice - the default), prefactor, expand, migrate (a migrate batch), contract, integrate (the integrate-and-verify ticket of a shared integration branch).what- one to three sentences: the end-to-end behaviour this ticket makes work, from the user's perspective, naming who can do what afterwards. Never a list of layers to touch.blocked_by- the numbers of the tickets that genuinely gate this one, e.g. "01, 03" (only earlier numbers), or "None (can start immediately)".covers- the requirement ids this ticket delivers, e.g. "R1, R4"; "" only for a prefactor or an expand that delivers none on its own.criteria- 2 to 7 acceptance criteria, each a checkable statement in one sentence; a slice's criteria include the test that proves it end to end.
The replies
slice
Break the spec into tickets: vertical slices, prefactors first, each with its blocking edges - or, when the facts carry a wide-refactor signal, the expand - migrate - contract sequence (or a shared integration branch with an integrate-and-verify ticket). When the spec is written by layer, every slice cuts through those sections; never one ticket per section.
summary- one sentence, at most 40 words: how the work is cut and why, with the number of tickets.tickets- the tickets, in the ticket shape above, in number order.uncovered- one object per requirement no ticket covers on purpose:id(the page's id),why(one sentence - it needs a decision first, or it is not work this breakdown can do).[]when every requirement is covered. Every requirement id is in some ticket's covers or here, never both.quiz- exactly three objects, the skill's questions asked about this breakdown:kind(granularity, edges or merge-split - one of each),question(one sentence naming the tickets by number, e.g. "Should 03 split rename from delete?").caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "slice",
"summary": "one sentence",
"tickets": [ { "number": "01", "title": "short name", "kind": "slice", "what": "one to three sentences", "blocked_by": "None (can start immediately)", "covers": "R1, R2", "criteria": [ "one checkable sentence" ] } ],
"uncovered": [ { "id": "R7", "why": "one sentence" } ],
"quiz": [ { "kind": "granularity", "question": "one sentence naming tickets" } ],
"caveats": [ ]
}
The page holds it to its own reading (check() in slicereply.js; any warning makes the reply flagged): the reply answers the task it was sent; its tickets pass the same graph check the free board runs - no duplicate number, self block, unknown blocker or cycle, numbered from 01 without gaps and in dependency order, no one-layer (horizontal) ticket, no What to build written as a layer list, every ticket with a title, a What to build and acceptance criteria, at most 7 criteria, no file path, prefactors before what they serve, and when the spec reads as a wide refactor an expand with every migrate batch blocked by it and a contract blocked by every batch (or an integrate-and-verify ticket); every requirement id is covered by a ticket or listed in uncovered with a reason, and no ticket cites an id the spec lacks; the quiz asks granularity, edges and merge-split once each; a summary; no web link, no gendered pronoun and no talk of the request's field names.
revise
Rework the current tickets on the user's feedback. Do what the feedback asks; fix every error in the page's check of the current tickets, and every warning the feedback does not ask you to keep; keep everything else as it was.
summary- one sentence, at most 40 words: what changed and what the breakdown looks like now.tickets- the full revised set, not only the changed tickets, in the ticket shape above, renumbered from 01 in dependency order.changes- one object per change:kind(one of merged, split, re-edged, renamed, added, removed, rescoped),tickets(the numbers involved, old to new where they moved, e.g. "02, 04 -> 02"),what(one sentence: what changed),because(one sentence: the part of the feedback, or the page's finding, it answers). A ticket that only moves number because another merged needs no change of its own.uncovered- as in the slice task.quiz- as in the slice task, asked about the revised tickets.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "revise",
"summary": "one sentence",
"tickets": [ { "number": "01", "title": "short name", "kind": "slice", "what": "one to three sentences", "blocked_by": "None (can start immediately)", "covers": "R1, R2", "criteria": [ "one checkable sentence" ] } ],
"changes": [ { "kind": "merged", "tickets": "02, 04 -> 02", "what": "one sentence", "because": "one sentence" } ],
"uncovered": [ ],
"quiz": [ { "kind": "edges", "question": "one sentence naming tickets" } ],
"caveats": [ ]
}
The page requires everything the slice lane requires of the revised tickets, and then reconciles the change log with what changed: at least one change, each with a known kind, what changed and the feedback it answers, naming only ticket numbers of the current or the revised set; more tickets than before needs a split or added change, fewer needs a merged or removed one; a set identical to the current one is flagged; changed blocking edges without a re-edged, merged or split change are noted, and so is a one-layer ticket the revision kept.
Worked examples
Three runs from the page's free samples (invented products): the slice of a small saved-searches spec, its revision on the sample's feedback, and the slice of a team-invitations spec written by layer. Each input is exactly what the page sends; each reply is the saved reply the sample replays, generated with every requirement id taken from the engine - but the spec, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
slice - A small feature: saved searches
The page read 7 requirements; the graph of the reply: 7 tickets, 5 waves (1: 01; 2: 02; 3: 03 04 05; 4: 06; 5: 07), critical path 01 -> 02 -> 05 -> 06 -> 07 (5 tickets), frontier 01, widest wave 3.
Input (facts is 1646 characters; the first 1392 are shown):
{
"task": "slice",
"feature": "Feature: Saved searches\nSpec: 20 lines, 195 words, 7 requirements",
"spec": "# Saved searches\n\n## Background\nJob seekers run the same search every morning. We want them to keep a search and hear about new matches without coming back to check.\n\n## Requirements\n- A signed-in job seeker can save the current search (keywords, location, filters) under a name of their choice.\n- Saved searches are listed on the account page, newest first, with the date each was saved.\n- A job seeker can rename or delete a saved search.\n- Opening a saved search re-runs it and marks the jobs posted since it was last opened as new.\n- A job seeker can opt in to a daily email digest per saved search; the digest lists up to 10 new matches and links back to the search.\n- Every digest email must carry a one-click unsubscribe link that turns that digest off.\n- A job seeker can keep at most 20 saved searches; trying to save a 21st explains the limit.\n\n## Notes\nThe search query is assembled inline in the results page today, which makes it hard to store. Pulling it into its own value first will make the rest easier.\n\n## Out of scope\n- Push notifications on mobile.\n- Sharing a saved search with another person.",
"tickets": "No tickets yet - draft the breakdown from the spec and the facts.",
"feedback": "No feedback yet - this is the first breakdown, so ask the three quiz questions about it.",
"facts": "The page's reading of the spec: 20 lines, 195 words, 7 requirements.\nRequirements (id | section | line | text):\n- R1 | Requirements | line 7 | A signed-in job seeker can save the current search (keywords, location, filters) under a name of their choice.\n- R2 | Requirements | line 8 | Saved searches are listed on the account page, newest first, with the date each was saved.\n- R3 | Requirements | line 9 | A job seeker can rename or delete a saved search.\n- R4 | Requirements | line 10 | Opening a saved search re-runs it and marks the jobs posted since it was last opened as new.\n- R5 | Requirements | line 11 | A job seeker can opt in to a daily email digest per saved search; the digest lists up to 10 new matches and links back to the search.\n- R6 | Requirements | line 12 | Every digest email must carry a one-click unsubscribe link that turns that digest off.\n- R7 | Requirements | line 13 | A job seeker can keep at most 20 saved searches; trying to save a 21st explains the limit.\nOut of scope - never ticket these: Push notifications on mobile. (line 19); Sharing a saved search with another person. (line 20).\nOpen questions in the spec: none.\nVague spots (notes, not blockers): none.\nWide-refactor signal: none - every ticket is a vertical slice or a prefactor.\nLayered spec: no.\nmust: cite every requirement id (R1 to R7) in some ticket's covers, or list it in uncovered with the\n\n[... 254 more characters cut from this display ...]"
}
Reply:
{
"task": "slice",
"summary": "Seven tickets: a prefactor that makes the search query storable, then six slices a job seeker can try - 5 waves, with the longest chain 01 -> 02 -> 05 -> 06 -> 07.",
"tickets": [
{
"number": "01",
"title": "Prefactor: one storable search query",
"kind": "prefactor",
"what": "The results page builds its search from a single query value that can be stored and replayed, so a job seeker sees exactly the same results as today and later tickets can keep a search without touching the results page again.",
"blocked_by": "None (can start immediately)",
"covers": "",
"criteria": [
"Running any search gives the same results and order as before",
"A query turned into its stored form and back runs the identical search",
"Nothing visible changes on the results page"
]
},
{
"number": "02",
"title": "Save a search and see it on the account page",
"kind": "slice",
"what": "A signed-in job seeker saves the search they are looking at under a name, and finds it on their account page, newest first, with the date it was saved.",
"blocked_by": "01",
"covers": "R1, R2",
"criteria": [
"A signed-in job seeker can name and save the current search, keywords, location and filters included",
"The account page lists saved searches newest first with the date each was saved",
"A guest who tries to save is asked to sign in first",
"An end-to-end test saves a search and finds it on the account page"
]
},
{
"number": "03",
"title": "Rename and delete a saved search",
"kind": "slice",
"what": "From the account page a job seeker renames a saved search or deletes it, and the list updates straight away.",
"blocked_by": "02",
"covers": "R3",
"criteria": [
"Renaming keeps the search and changes only its name",
"Deleting asks for confirmation and removes it from the list",
"A job seeker cannot rename or delete another person's saved search"
]
},
{
"number": "04",
"title": "Cap saved searches at 20",
"kind": "slice",
"what": "A job seeker who already keeps 20 saved searches and tries to save another is told about the limit and what to do, and nothing is saved.",
"blocked_by": "02",
"covers": "R7",
"criteria": [
"Saving a 21st search is refused with a message that explains the limit of 20",
"Deleting one makes room to save again"
]
},
{
"number": "05",
"title": "Open a saved search and see what is new",
"kind": "slice",
"what": "A job seeker opens a saved search, it runs again, and the jobs posted since they last opened it are marked as new.",
"blocked_by": "02",
"covers": "R4",
"criteria": [
"Opening a saved search runs it with its stored query",
"Jobs posted since the previous opening carry a new marker",
"Opening it again clears the markers for jobs already seen"
]
},
{
"number": "06",
"title": "Daily email digest per saved search",
"kind": "slice",
"what": "A job seeker turns on a daily digest for a saved search and receives one email a day listing up to 10 new matches, with a link back to the search.",
"blocked_by": "05",
"covers": "R5",
"criteria": [
"The digest can be switched on and off per saved search",
"One email a day lists up to 10 matches that are new since the last digest",
"No email is sent on a day with no new match",
"The email links back to the saved search"
]
},
{
"number": "07",
"title": "One-click unsubscribe from a digest",
"kind": "slice",
"what": "Every digest email carries a link that turns that digest off in one click, without signing in.",
"blocked_by": "06",
"covers": "R6",
"criteria": [
"Every digest email carries an unsubscribe link",
"Following it turns off that one digest and confirms on the page",
"Other digests of the same job seeker keep arriving"
]
}
],
"uncovered": [],
"quiz": [
{
"kind": "granularity",
"question": "Is 04, the 20-search cap, worth its own ticket, or is it one check that belongs inside 02?"
},
{
"kind": "edges",
"question": "Does 06 really need 05's new-since-last-opened marker, or could the digest keep its own last-sent date and start right after 02?"
},
{
"kind": "merge-split",
"question": "Should 03 split rename from delete, or is one ticket for both the right size?"
}
],
"caveats": [
"Push notifications and sharing a saved search are out of scope in the spec and have no ticket."
]
}
What step 5 prints for it: 01: Prefactor: one storable search query (blocked by none) | 02: Save a search and see it on the account page (blocked by 01) | 03: Rename and delete a saved search (blocked by 02) | 04: Cap saved searches at 20 (blocked by 02) | 05: Open a saved search and see what is new (blocked by 02) | 06: Daily email digest per saved search (blocked by 05) | 07: One-click unsubscribe from a digest (blocked by 06)
The page's check: kept - nothing to flag
revise - A small feature: saved searches
The input carries the saved slice's tickets (R.laneDesk fills tickets from the slice reply), as the page's handoff does - the current graph: 7 tickets, 5 waves (1: 01; 2: 02; 3: 03 04 05; 4: 06; 5: 07), critical path 01 -> 02 -> 05 -> 06 -> 07 (5 tickets), frontier 01, widest wave 3. The graph of the revision: 6 tickets, 4 waves (1: 01; 2: 02; 3: 03 04 05; 4: 06), critical path 01 -> 02 -> 05 -> 06 (4 tickets), frontier 01, widest wave 3.
Input (facts is 2202 characters; the first 1398 are shown; tickets is 4145 characters; the first 1398 are shown):
{
"task": "revise",
"feature": "Feature: Saved searches\nSpec: 20 lines, 195 words, 7 requirements",
"spec": "# Saved searches\n\n## Background\nJob seekers run the same search every morning. We want them to keep a search and hear about new matches without coming back to check.\n\n## Requirements\n- A signed-in job seeker can save the current search (keywords, location, filters) under a name of their choice.\n- Saved searches are listed on the account page, newest first, with the date each was saved.\n- A job seeker can rename or delete a saved search.\n- Opening a saved search re-runs it and marks the jobs posted since it was last opened as new.\n- A job seeker can opt in to a daily email digest per saved search; the digest lists up to 10 new matches and links back to the search.\n- Every digest email must carry a one-click unsubscribe link that turns that digest off.\n- A job seeker can keep at most 20 saved searches; trying to save a 21st explains the limit.\n\n## Notes\nThe search query is assembled inline in the results page today, which makes it hard to store. Pulling it into its own value first will make the rest easier.\n\n## Out of scope\n- Push notifications on mobile.\n- Sharing a saved search with another person.",
"tickets": "<!-- the current tickets - 7 tickets, one file each below, in dependency order -->\n\n<!-- file: 01-prefactor-one-storable-search-query.md -->\n\n# 01: Prefactor: one storable search query\n\n**What to build:** The results page builds its search from a single query value that can be stored and replayed, so a job seeker sees exactly the same results as today and later tickets can keep a search without touching the results page again.\n\n**Blocked by:** None (can start immediately)\n\n**Status:** ready-for-agent\n\n**Kind:** prefactor\n\n- [ ] Running any search gives the same results and order as before\n- [ ] A query turned into its stored form and back runs the identical search\n- [ ] Nothing visible changes on the results page\n\n---\n\n<!-- file: 02-save-a-search-and-see-it-on-the-account-page.md -->\n\n# 02: Save a search and see it on the account page\n\n**What to build:** A signed-in job seeker saves the search they are looking at under a name, and finds it on their account page, newest first, with the date it was saved.\n\n**Blocked by:** 01 (Prefactor: one storable search query)\n\n**Status:** ready-for-agent\n\n**Covers:** R1, R2\n\n- [ ] A signed-in job seeker can name and save the current search, keywords, location and filters included\n- [ ] The account page lists saved searches newest first with the date each was saved\n- [ ] A guest who tries to save is asked to sign in first\n- [ ] An end-to-end\n\n[... 2747 more characters cut from this display ...]",
"feedback": "Fold the 20-search cap into saving - it is one check - and let the digest start straight after saving: it can keep its own last-sent date instead of waiting on the new-since-opened work.",
"facts": "The page's reading of the spec: 20 lines, 195 words, 7 requirements.\nRequirements (id | section | line | text):\n- R1 | Requirements | line 7 | A signed-in job seeker can save the current search (keywords, location, filters) under a name of their choice.\n- R2 | Requirements | line 8 | Saved searches are listed on the account page, newest first, with the date each was saved.\n- R3 | Requirements | line 9 | A job seeker can rename or delete a saved search.\n- R4 | Requirements | line 10 | Opening a saved search re-runs it and marks the jobs posted since it was last opened as new.\n- R5 | Requirements | line 11 | A job seeker can opt in to a daily email digest per saved search; the digest lists up to 10 new matches and links back to the search.\n- R6 | Requirements | line 12 | Every digest email must carry a one-click unsubscribe link that turns that digest off.\n- R7 | Requirements | line 13 | A job seeker can keep at most 20 saved searches; trying to save a 21st explains the limit.\nOut of scope - never ticket these: Push notifications on mobile. (line 19); Sharing a saved search with another person. (line 20).\nOpen questions in the spec: none.\nVague spots (notes, not blockers): none.\nWide-refactor signal: none - every ticket is a vertical slice or a prefactor.\nLayered spec: no.\nThe current tickets, as the page reads them: 7 tickets, 5 waves (1: 01; 2: 02; 3: 03 04 05; 4: 06; 5: 07),\n\n[... 804 more characters cut from this display ...]"
}
Reply:
{
"task": "revise",
"summary": "The cap folds into saving and the digest now starts straight after saving, so six tickets run in 4 waves and the longest chain drops to 01 -> 02 -> 05 -> 06.",
"tickets": [
{
"number": "01",
"title": "Prefactor: one storable search query",
"kind": "prefactor",
"what": "The results page builds its search from a single query value that can be stored and replayed, so a job seeker sees exactly the same results as today and later tickets can keep a search without touching the results page again.",
"blocked_by": "None (can start immediately)",
"covers": "",
"criteria": [
"Running any search gives the same results and order as before",
"A query turned into its stored form and back runs the identical search",
"Nothing visible changes on the results page"
]
},
{
"number": "02",
"title": "Save a search, capped at 20, and see it on the account page",
"kind": "slice",
"what": "A signed-in job seeker saves the search they are looking at under a name and finds it on their account page, newest first with the date it was saved; once they keep 20, saving another explains the limit instead.",
"blocked_by": "01",
"covers": "R1, R2, R7",
"criteria": [
"A signed-in job seeker can name and save the current search, keywords, location and filters included",
"The account page lists saved searches newest first with the date each was saved",
"Saving a 21st search is refused with a message that explains the limit of 20",
"A guest who tries to save is asked to sign in first",
"An end-to-end test saves a search and finds it on the account page"
]
},
{
"number": "03",
"title": "Rename and delete a saved search",
"kind": "slice",
"what": "From the account page a job seeker renames a saved search or deletes it, and the list updates straight away.",
"blocked_by": "02",
"covers": "R3",
"criteria": [
"Renaming keeps the search and changes only its name",
"Deleting asks for confirmation and removes it from the list",
"A job seeker cannot rename or delete another person's saved search"
]
},
{
"number": "04",
"title": "Open a saved search and see what is new",
"kind": "slice",
"what": "A job seeker opens a saved search, it runs again, and the jobs posted since they last opened it are marked as new.",
"blocked_by": "02",
"covers": "R4",
"criteria": [
"Opening a saved search runs it with its stored query",
"Jobs posted since the previous opening carry a new marker",
"Opening it again clears the markers for jobs already seen"
]
},
{
"number": "05",
"title": "Daily email digest per saved search",
"kind": "slice",
"what": "A job seeker turns on a daily digest for a saved search and receives one email a day listing up to 10 matches posted since the last digest it sent, with a link back to the search.",
"blocked_by": "02",
"covers": "R5",
"criteria": [
"The digest can be switched on and off per saved search",
"Each digest keeps its own last-sent date and lists up to 10 matches posted since then",
"No email is sent on a day with no new match",
"The email links back to the saved search"
]
},
{
"number": "06",
"title": "One-click unsubscribe from a digest",
"kind": "slice",
"what": "Every digest email carries a link that turns that digest off in one click, without signing in.",
"blocked_by": "05",
"covers": "R6",
"criteria": [
"Every digest email carries an unsubscribe link",
"Following it turns off that one digest and confirms on the page",
"Other digests of the same job seeker keep arriving"
]
}
],
"changes": [
{
"kind": "merged",
"tickets": "02, 04 -> 02",
"what": "The 20-search cap is now a criterion of saving instead of its own ticket; the later tickets move up one number.",
"because": "The cap is one check, so it belongs inside saving."
},
{
"kind": "re-edged",
"tickets": "05",
"what": "The digest waits only on saving and keeps its own last-sent date, so it no longer waits on opening a saved search.",
"because": "The digest can start straight after saving."
},
{
"kind": "rescoped",
"tickets": "05",
"what": "The digest lists matches posted since its own last send rather than since the search was last opened.",
"because": "It keeps its own last-sent date instead of the new-since-opened work."
}
],
"uncovered": [],
"quiz": [
{
"kind": "granularity",
"question": "With the cap inside it, is 02 still small enough for one sitting, or should the account-page list become its own slice?"
},
{
"kind": "edges",
"question": "03, 04 and 05 now all start after 02 - is there anything in 04 that 05 still needs?"
},
{
"kind": "merge-split",
"question": "Should 06, the unsubscribe link, merge into 05 so the digest never ships without it?"
}
],
"caveats": []
}
What step 5 prints for it: 01: Prefactor: one storable search query (blocked by none) | 02: Save a search, capped at 20, and see it on the account page (blocked by 01) | 03: Rename and delete a saved search (blocked by 02) | 04: Open a saved search and see what is new (blocked by 02) | 05: Daily email digest per saved search (blocked by 02) | 06: One-click unsubscribe from a digest (blocked by 05)
The page's check: kept - nothing to flag
slice - A spec written by layer, with layer tickets
The page read 9 requirements; the graph of the reply: 3 tickets, 2 waves (1: 01; 2: 02 03), critical path 01 -> 02 (2 tickets), frontier 01, widest wave 2.
Input (facts is 1751 characters; the first 1396 are shown):
{
"task": "slice",
"feature": "Feature: Team invitations\nSpec: 20 lines, 152 words, 9 requirements",
"spec": "# Team invitations\n\nAdmins can invite people to their team by email. Today an admin asks support to add someone, and it takes a day.\n\n## Database\n- An invitations table stores the email, the role (member or admin), a single-use token, who sent it and when it expires (7 days after sending).\n- Accepting an invitation adds a membership with the invited role.\n\n## API\n- Admins can create an invitation; anyone else gets a 403.\n- An invite link can be accepted once, by a signed-in user whose email matches; an expired or used link says so.\n- Admins can revoke a pending invitation.\n- Sending an invitation emails the link to the invitee.\n\n## Frontend\n- Team settings shows an invite form (email and role) and the list of pending invitations, each with a revoke button.\n- The accept page shows the team name and the role before the user accepts.\n\n## Tests\n- Every rule above is covered by an integration test.",
"tickets": "No tickets yet - draft the breakdown from the spec and the facts.",
"feedback": "No feedback yet - this is the first breakdown, so ask the three quiz questions about it.",
"facts": "The page's reading of the spec: 20 lines, 152 words, 9 requirements.\nRequirements (id | section | line | text):\n- R1 | Database | line 6 | An invitations table stores the email, the role (member or admin), a single-use token, who sent it and when it expires (7 days after sending).\n- R2 | Database | line 7 | Accepting an invitation adds a membership with the invited role.\n- R3 | API | line 10 | Admins can create an invitation; anyone else gets a 403.\n- R4 | API | line 11 | An invite link can be accepted once, by a signed-in user whose email matches; an expired or used link says so.\n- R5 | API | line 12 | Admins can revoke a pending invitation.\n- R6 | API | line 13 | Sending an invitation emails the link to the invitee.\n- R7 | Frontend | line 16 | Team settings shows an invite form (email and role) and the list of pending invitations, each with a revoke button.\n- R8 | Frontend | line 17 | The accept page shows the team name and the role before the user accepts.\n- R9 | Tests | line 20 | Every rule above is covered by an integration test.\nOut of scope - never ticket these: none.\nOpen questions in the spec: none.\nVague spots (notes, not blockers): none.\nWide-refactor signal: none - every ticket is a vertical slice or a prefactor.\nLayered spec: yes - the spec is organised by layer (Database, API, Frontend, Tests); each ticket cuts through those sections, never along one of them..\n\n[... 355 more characters cut from this display ...]"
}
Reply:
{
"task": "slice",
"summary": "Three vertical slices instead of four layer tickets: inviting, accepting and revoking each run from the screen through to the stored invitation, with the tests inside each - 2 waves, longest chain 01 -> 02.",
"tickets": [
{
"number": "01",
"title": "An admin invites a teammate by email",
"kind": "slice",
"what": "An admin enters an email and a role on team settings and the invitee receives an email with a single-use link; the invitation is kept with its sender and a 7-day expiry, and it shows in the pending list.",
"blocked_by": "None (can start immediately)",
"covers": "R1, R3, R6, R7, R9",
"criteria": [
"An admin can send an invitation with an email and a role of member or admin",
"Anyone who is not an admin is refused with a 403",
"The invitee receives an email with a single-use link that expires after 7 days",
"The invitation appears in the pending list on team settings",
"Integration tests cover sending, the 403 and the expiry date"
]
},
{
"number": "02",
"title": "An invitee accepts and joins the team",
"kind": "slice",
"what": "The invitee follows the link, sees the team name and the role, accepts while signed in with the invited email, and becomes a member with that role; a used or expired link says so.",
"blocked_by": "01",
"covers": "R2, R4, R8, R9",
"criteria": [
"The accept page shows the team name and the role before accepting",
"Accepting with the matching email adds a membership with the invited role",
"A second use of the same link, an expired link or a different email is refused with a clear message",
"Integration tests cover each of those cases"
]
},
{
"number": "03",
"title": "An admin revokes a pending invitation",
"kind": "slice",
"what": "An admin revokes a pending invitation from the list on team settings, and its link stops working at once.",
"blocked_by": "01",
"covers": "R5, R7, R9",
"criteria": [
"Each pending invitation has a revoke button for admins",
"A revoked link is refused with a message that it was withdrawn",
"The pending list no longer shows it",
"Integration tests cover revoking and the refused link"
]
}
],
"uncovered": [],
"quiz": [
{
"kind": "granularity",
"question": "01 carries the table, the email and the form - is it still one sitting, or should sending the email become its own slice after 01?"
},
{
"kind": "edges",
"question": "02 and 03 both wait only on 01 - does revoking need anything from accepting, or can they really run in parallel?"
},
{
"kind": "merge-split",
"question": "Should the 403 for non-admins in 01 split out, or merge with 03 as one admin-only slice?"
}
],
"caveats": []
}
What step 5 prints for it: 01: An admin invites a teammate by email (blocked by none) | 02: An invitee accepts and joins the team (blocked by 01) | 03: An admin revokes a pending invitation (blocked by 01)
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./slicekit.js"); require("./slicereply.js")), then:
const K = SliceKit, R = SliceReply;
const f = K.desk({ name: "Exports", feedback: "",
spec: "# Exports\n\n- An admin can export every invoice as CSV.\n- The export must include the customer name.",
tickets: "# 01: Export invoices\n\n**What to build:** An admin downloads every invoice as CSV.\n\n**Blocked by:** 02\n\n- [ ] The file opens in a spreadsheet\n\n# 02: CSV writer\n\n**What to build:** A CSV writer.\n\n**Blocked by:** 01\n\n- [ ] It writes CSV" });
// f.spec.reqs (id, section, line, text), f.tickets, f.an (waves, frontier, critical, cycles), f.findings (id, rule, sev, tickets, evidence, first, fix)
// K.allMd(f.tickets), K.issuesMd(f.tickets), K.ticketsCsv(f.tickets, f.an), K.mermaid(f.tickets, f.an) - the free exports
const b = K.buildInput(f, "slice"); // or "revise" (needs tickets and feedback)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "slice");
// R.check(reply, b) // {status: kept or flags, notes}
That example's two tickets block each other, so the check names the cycle (cycle, undersized, undersized, no-covers) - fix the graph free before you pay for a run. Get a token for the steps below from /tokens.html.
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="YOUR_TOKEN" # from https://slice-desk.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 = "YOUR_TOKEN" # from https://slice-desk.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"]
// slice-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://slice-desk.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 = "YOUR_TOKEN" // from https://slice-desk.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
}
// SliceDesk.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 SliceDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://slice-desk.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 = "YOUR_TOKEN" # from https://slice-desk.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 = "YOUR_TOKEN"; // from https://slice-desk.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 = "YOUR_TOKEN"; // from https://slice-desk.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":"slice-desk"}' | 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": "slice-desk"})["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: "slice-desk" })).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": "slice-desk"}, 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", "slice-desk"), 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: "slice-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "slice-desk"])["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 = "slice-desk" }))!["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": "slice", "feature": "Feature: Saved searches
Spec: 20 lines, 190 words, 7 requirements", "spec": "# Saved searches
## Requirements
- A signed-in job seeker can save the current search ...
...", "tickets": "No tickets yet - draft the breakdown from the spec and the facts.", "feedback": "No feedback yet - this is the first breakdown, so ask the three quiz questions about it.", "facts": "The page's reading of the spec: 20 lines, 190 words, 7 requirements.
Requirements (id | section | line | text):
- R1 | Requirements | line 7 | A signed-in job seeker can save ..."}
# - see the input 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": "slice", "feature": "Feature: Saved searches
Spec: 20 lines, 190 words, 7 requirements", "spec": "# Saved searches
## Requirements
- A signed-in job seeker can save the current search ...
...", "tickets": "No tickets yet - draft the breakdown from the spec and the facts.", "feedback": "No feedback yet - this is the first breakdown, so ask the three quiz questions about it.", "facts": "The page's reading of the spec: 20 lines, 190 words, 7 requirements.
Requirements (id | section | line | text):
- R1 | Requirements | line 7 | A signed-in job seeker can save ..."}
# - see the input 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: "slice" or "revise", feature, spec, tickets, feedback, facts} (see the input 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 slice-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A slice reply carries summary, tickets, uncovered, quiz and caveats; a revise reply carries summary, tickets, changes, uncovered, quiz and caveats. The code below prints every ticket of either, with what blocks it.
KEY="slice-desk:$(jq -r .task input.json):$(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 '.task, (.tickets[] | "\(.number): \(.title) - blocked by \(.blocked_by)")' reply.json
import hashlib, time
key = "slice-desk:" + 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["task"])
for t in reply["tickets"]:
print(t["number"], t["title"], "- blocked by", t["blocked_by"])
import { createHash } from "node:crypto";
const key = `slice-desk:${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.task);
for (const t of reply.tickets) console.log(t.number, t.title, "- blocked by", t.blocked_by);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "slice-desk:" + 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)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
ts, _ := reply["tickets"].([]any)
for _, t := range ts {
tt := t.(map[string]any)
fmt.Println(tt["number"], tt["title"], "- blocked by", tt["blocked_by"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "slice-desk:" + 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.path("task").asText());
for (JsonNode t : reply.path("tickets")) System.out.println(t.path("number").asText() + " " + t.path("title").asText() + " - blocked by " + t.path("blocked_by").asText());
require "digest"
key = "slice-desk:#{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["task"]].inspect
reply["tickets"].each { |t| puts "#{t["number"]} #{t["title"]} - blocked by #{t["blocked_by"]}" }
$key = "slice-desk:" . $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["task"], "\n";
foreach ($reply["tickets"] as $t) echo $t["number"], " ", $t["title"], " - blocked by ", $t["blocked_by"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"slice-desk:{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["task"]}");
foreach (var t in reply["tickets"]!.AsArray()) Console.WriteLine($"{t!["number"]} {t["title"]} - blocked by {t["blocked_by"]}");
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: slice-desk:$(jq -r .task input.json):$(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": "slice", "feature": "Feature: Saved searches
Spec: 20 lines, 190 words, 7 requirements", "spec": "# Saved searches
## Requirements
- A signed-in job seeker can save the current search ...
...", "tickets": "No tickets yet - draft the breakdown from the spec and the facts.", "feedback": "No feedback yet - this is the first breakdown, so ask the three quiz questions about it.", "facts": "The page's reading of the spec: 20 lines, 190 words, 7 requirements.
Requirements (id | section | line | text):
- R1 | Requirements | line 7 | A signed-in job seeker can save ..."}
# - see the input 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": "slice-desk:" + rev["task"] + ":" + 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": `slice-desk:${rev.task}:${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", "slice-desk:"+input["task"].(string)+":"+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", "slice-desk:" + input.get("task") + ":" + 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" => "slice-desk:#{JSON.parse(rev)["task"]}:#{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: slice-desk:" . json_decode($rev, true)["task"] . ":" . 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", $"slice-desk:{input["task"]}:{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);