← Deep Hours
API

Drive Deep Hours from your own code

The one metered task, focus, is available over HTTP. It takes a working week as the page reads it - what the focus time is for, the weekly target in hours, the shortest block worth having, the time of day to favour, the working days and hours, every recurring meeting as one numbered line (m1, m2 ...), every free stretch long enough for focus as one numbered line (g1, g2 ...), the free total and the meetings whose move would open the most focus time - and returns the focus blocks, each placed inside one free stretch with a title and a reason, a verdict (met or short), the meetings worth moving when the week cannot hold the target, a status line for each calendar block and a note.

The division of labour first: the page reads the meetings and finds every free stretch exactly; the model chooses which stretches to protect and how much of each, names the blocks and says which meeting is worth moving, referring to stretches and meetings only by id so the page can check every time. The page reads up to 30 meeting lines, keeps 10 minutes clear before and after each meeting, keeps only stretches at least the shortest block long inside the working hours, and lists the three meetings whose move would add the most focus-length free time. After the reply it keeps only blocks that sit inside a stretch it sent, on the quarter hour, between the shortest block and 180 minutes long, one per stretch, re-checks the verdict against the blocks' own total and every number the reply quotes, and builds the calendar file. Your code can do the same with the engine files the page loads (see "Build the input yourself").

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"deep-hours"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, goal, target_hours, min_block, prefer, workdays, hours, meetings, gaps, free_total, move_options) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the focus run is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field, the input and the rules

Every run sends task: focus (the one task is focus); a missing or different one is still answered as focus. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways focus.
goalyesWhat the focus time is for, in the person's words (at most 140 characters): Writing the quarterly report.
target_hoursyesThe focus hours wanted a week, 1 to 30, rounded to the quarter hour, as a string (10, 7.5).
min_blockyesThe shortest block worth having, in minutes: 60, 90 or 120.
preferyesmorning (earliest stretches first), afternoon (latest first) or any (longest first).
workdaysyesThe working days in week order: Mon, Tue, Wed, Thu, Fri (also Sun-Thu or Mon-Sat).
hoursyesThe working hours as HH:MM-HH:MM: 09:00-17:30.
meetingsyesOne line per recurring meeting, at most 30: m2. Tue, Thu 14:00-15:00 client calls - id, days, time and title - or none.
gapsyesOne line per free stretch, in day then time order: g1. Mon 09:55-12:20 (145 min).
free_totalyesThe free time in those stretches as the page counts it: 22.5 hours in 8 stretches of 90+ minutes.
move_optionsyesUp to three meetings, most gain first, and what moving each would add: m4. retro (Fri 15:00-16:00): moving it adds 245 min of focus-length free time, or none.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads the week (WeekKit): each meeting line holds its days anywhere (Mon, Tue-Thu, Mon & Wed, daily, weekdays), one time range (9:30-10, 2-3pm, 14:00-15:30) and a title, which is whatever is left. A range like 2-3pm puts both ends in the afternoon, and a range with no am or pm that starts before 7 is read as the afternoon. A line that names no day is read as every working day, and a line with no readable range is skipped; the page warns about both. On each working day the page blocks out every meeting plus 10 minutes either side and keeps each free stretch at least the shortest block long inside the working hours. For every meeting it also measures how much more free time in stretches of that length the week would have without it, and sends the best three. The page needs a goal of a few words and at least one free stretch before it will send.

The free fill (WeekKit.freeFill) needs no run: it takes the stretches in the preferred order, one day at a time before a second stretch on any day, and places a block on the quarter hour, up to 180 minutes, until the target is reached; the last block is trimmed but never below the shortest block. The run does the same choosing with a title and a reason for each block, and the advice on what to move.

The model's rules: at most one block per stretch, in day then time order, each on the quarter hour (minutes 00, 15, 30 or 45) inside its stretch, at least the shortest block and at most 180 minutes long. Stretches are chosen by the preference - earliest, latest or longest first - on different days before a second on the same day, and blocks are added until their total reaches the target, going over by no more than 30 minutes, or as far as the stretches allow. A title has at most 6 words from the goal's own words; a reason is one sentence of at most 16 words using only the stretch's day, time and length. The verdict is met when the blocks' total reaches the target, otherwise short; only then does the reply suggest at most two meetings from the move options, preferring a once-a-week meeting over a daily routine or a meal break, and always moving, never cancelling, declining or skipping. Sentences refer to stretches and meetings by day, time and title, never by id, and quote only numbers from the input or the blocks' own total.

The reply

One JSON object:

{"task": "focus", "verdict": "short", "blocks": [{"gap": "g1", "start": "13:30", "end": "16:30", "title": "...", "why": "..."}], "move": [{"meeting": "m5", "why": "..."}], "status_line": "...", "note": "..."}
FieldWhat it holds
verdictmet (the blocks reach the target) or short (the week cannot hold it).
blocks[].gapThe id of the free stretch the block sits in; at most one block per stretch.
blocks[].start, blocks[].endHH:MM on the quarter hour, inside the stretch.
blocks[].titleAt most 6 words, from the goal's own words; it becomes the calendar event's title.
blocks[].whyOne sentence: why this stretch suits, using only its day, time and length.
move[]{meeting, why}, for short only: at most two meetings from the move options, each with one sentence naming the meeting and the minutes moving it adds. [] for met.
status_lineOne sentence for the description of every calendar block: protected focus time and what it is for, with no times or names.
noteOne sentence: the hours the blocks give against the target and, for short, what the move would open.

The page reads the reply with ReplyKit.normalize (ids matched in any case, times read as HH:MM) and keeps what it trusts with ReplyKit.trusted(reply, built): a block is dropped, with the reason, when it names a stretch the page did not find, when its times are not HH:MM or not on the quarter hour, when it runs outside its stretch, when it is shorter than the shortest block or longer than three hours, or when its stretch already has a block. The rest are sorted by day and time, and their total is what the page checks the verdict against. Move suggestions are shown only for short, only for meetings among the page's own options, and at most two.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one the blocks' own total contradicts; every dropped block, or no usable block at all; blocks more than half an hour over the target; a short week that still has unused stretches; a day with two blocks while another day has a free stretch with none; a move suggested when the target is met, a meeting not among the options, or more than two moves; any number in a title, a reason, the status line or the note that is not in the input or the blocks; a sentence that names an id or an internal field; any word about cancelling, declining or skipping; words such as "still" or "always" the goal and meetings never used; and a status line that gives a time. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Target met" or "The week is too full".

The downloads: the calendar file (WeekKit.toICS(blocks, {from, note, uid})) holds one weekly repeating event per trusted block, in floating local time so the calendar's own time zone applies, starting on the first matching weekday on or after from (a YYYY-MM-DD date, or today) and carrying the status line as its description. ReplyKit.plain copies the blocks as text, ReplyKit.md and ReplyKit.json write the plan with the meetings worth moving, and the week timetable saves as PNG or SVG.

Worked examples - every sample

These are the free samples on the page: the week as typed, the meetings and free stretches the page reads, the exact input it sends, the reply that replays for free, and what the page does with it - the blocks it keeps, the meetings worth moving and its own check. Together they cover the verdicts met and short.

Writing a quarterly report

The form: Writing the quarterly report; 10 hours a week in blocks of at least 90 minutes, earliest stretches first; working hours 09:00-17:30, Mon-Fri. The meetings as typed:

daily 9:30-9:45 standup
Tue, Thu 2-3pm client calls
Wed 11:00-12:00 team review
Fri 15:00-16:00 retro
weekdays 12:30-13:15 lunch

The meetings as the page reads them, the free stretches it finds (22.5 hours in 8 stretches of 90+ minutes) and the move options (WeekKit.buildInput):

m1. Mon, Tue, Wed, Thu, Fri 09:30-09:45 standup
m2. Tue, Thu 14:00-15:00 client calls
m3. Wed 11:00-12:00 team review
m4. Fri 15:00-16:00 retro
m5. Mon, Tue, Wed, Thu, Fri 12:30-13:15 lunch
g1. Mon 09:55-12:20 (145 min)
g2. Mon 13:25-17:30 (245 min)
g3. Tue 09:55-12:20 (145 min)
g4. Tue 15:10-17:30 (140 min)
g5. Wed 13:25-17:30 (245 min)
g6. Thu 09:55-12:20 (145 min)
g7. Thu 15:10-17:30 (140 min)
g8. Fri 09:55-12:20 (145 min)
m5. lunch (Mon, Tue, Wed, Thu, Fri 12:30-13:15): moving it adds 470 min of focus-length free time
m1. standup (Mon, Tue, Wed, Thu, Fri 09:30-09:45): moving it adds 330 min of focus-length free time
m4. retro (Fri 15:00-16:00): moving it adds 245 min of focus-length free time

The free fill, placed with no run (WeekKit.freeFill):

Mon 10:00-12:15  Focus time
Tue 10:00-12:15  Focus time
Wed 13:30-15:00  Focus time
Thu 10:00-12:15  Focus time
Fri 10:00-12:15  Focus time

Input:

{
 "task": "focus",
 "goal": "Writing the quarterly report",
 "target_hours": "10",
 "min_block": "90",
 "prefer": "morning",
 "workdays": "Mon, Tue, Wed, Thu, Fri",
 "hours": "09:00-17:30",
 "meetings": "m1. Mon, Tue, Wed, Thu, Fri 09:30-09:45 standup\nm2. Tue, Thu 14:00-15:00 client calls\nm3. Wed 11:00-12:00 team review\nm4. Fri 15:00-16:00 retro\nm5. Mon, Tue, Wed, Thu, Fri 12:30-13:15 lunch",
 "gaps": "g1. Mon 09:55-12:20 (145 min)\ng2. Mon 13:25-17:30 (245 min)\ng3. Tue 09:55-12:20 (145 min)\ng4. Tue 15:10-17:30 (140 min)\ng5. Wed 13:25-17:30 (245 min)\ng6. Thu 09:55-12:20 (145 min)\ng7. Thu 15:10-17:30 (140 min)\ng8. Fri 09:55-12:20 (145 min)",
 "free_total": "22.5 hours in 8 stretches of 90+ minutes",
 "move_options": "m5. lunch (Mon, Tue, Wed, Thu, Fri 12:30-13:15): moving it adds 470 min of focus-length free time\nm1. standup (Mon, Tue, Wed, Thu, Fri 09:30-09:45): moving it adds 330 min of focus-length free time\nm4. retro (Fri 15:00-16:00): moving it adds 245 min of focus-length free time"
}

Reply:

{
 "task": "focus",
 "verdict": "met",
 "blocks": [
  {
   "gap": "g1",
   "start": "10:00",
   "end": "12:15",
   "title": "Writing the quarterly report",
   "why": "Monday's 09:55 to 12:20 stretch is 145 minutes, long enough for a focus block."
  },
  {
   "gap": "g3",
   "start": "10:00",
   "end": "12:15",
   "title": "Writing the quarterly report",
   "why": "Tuesday's 09:55 to 12:20 stretch is 145 minutes, long enough for a focus block."
  },
  {
   "gap": "g5",
   "start": "13:30",
   "end": "15:00",
   "title": "Writing the quarterly report",
   "why": "Wednesday's 13:25 to 17:30 stretch is 245 minutes, plenty for an afternoon focus block."
  },
  {
   "gap": "g6",
   "start": "10:00",
   "end": "12:15",
   "title": "Writing the quarterly report",
   "why": "Thursday's 09:55 to 12:20 stretch is 145 minutes, long enough for a focus block."
  },
  {
   "gap": "g8",
   "start": "10:00",
   "end": "12:15",
   "title": "Writing the quarterly report",
   "why": "Friday's 09:55 to 12:20 stretch is 145 minutes, long enough for a focus block."
  }
 ],
 "move": [],
 "status_line": "This is protected focus time for writing the quarterly report.",
 "note": "These blocks give 10.5 hours of focus time against a target of 10 hours."
}

The blocks the page keeps (ReplyKit.trusted), 10.5 hours against a target of 10:

The verdict: met - "Target met"; it follows the blocks' total (10.5 hours against 10).

Worth moving: nothing - the week holds the target.

On each block: "This is protected focus time for writing the quarterly report."

The page's check (ReplyKit.check): kept.

No notes.

A meeting-heavy week

The form: Learning the new data platform; 12 hours a week in blocks of at least 120 minutes, longest stretches first; working hours 09:00-17:30, Mon-Fri. The meetings as typed:

daily 9:30-9:45 standup
weekdays 12:30-13:15 lunch
Mon 11:00-12:00 planning
Tue 10:30-11:30 vendor call
Tue 15:00-16:00 design review
Wed 11:00-12:00 hiring panel
Thu 10:30-11:30 1:1 with manager
Thu 14:30-15:30 architecture forum
Fri 11:00-12:00 demo prep

The meetings as the page reads them, the free stretches it finds (12.25 hours in 3 stretches of 120+ minutes) and the move options (WeekKit.buildInput):

m1. Mon, Tue, Wed, Thu, Fri 09:30-09:45 standup
m2. Mon, Tue, Wed, Thu, Fri 12:30-13:15 lunch
m3. Mon 11:00-12:00 planning
m4. Tue 10:30-11:30 vendor call
m5. Tue 15:00-16:00 design review
m6. Wed 11:00-12:00 hiring panel
m7. Thu 10:30-11:30 1:1 with manager
m8. Thu 14:30-15:30 architecture forum
m9. Fri 11:00-12:00 demo prep
g1. Mon 13:25-17:30 (245 min)
g2. Wed 13:25-17:30 (245 min)
g3. Fri 13:25-17:30 (245 min)
m2. lunch (Mon, Tue, Wed, Thu, Fri 12:30-13:15): moving it adds 575 min of focus-length free time
m5. design review (Tue 15:00-16:00): moving it adds 245 min of focus-length free time
m8. architecture forum (Thu 14:30-15:30): moving it adds 245 min of focus-length free time

The free fill, placed with no run (WeekKit.freeFill):

Mon 13:30-16:30  Focus time
Wed 13:30-16:30  Focus time
Fri 13:30-16:30  Focus time

Input:

{
 "task": "focus",
 "goal": "Learning the new data platform",
 "target_hours": "12",
 "min_block": "120",
 "prefer": "any",
 "workdays": "Mon, Tue, Wed, Thu, Fri",
 "hours": "09:00-17:30",
 "meetings": "m1. Mon, Tue, Wed, Thu, Fri 09:30-09:45 standup\nm2. Mon, Tue, Wed, Thu, Fri 12:30-13:15 lunch\nm3. Mon 11:00-12:00 planning\nm4. Tue 10:30-11:30 vendor call\nm5. Tue 15:00-16:00 design review\nm6. Wed 11:00-12:00 hiring panel\nm7. Thu 10:30-11:30 1:1 with manager\nm8. Thu 14:30-15:30 architecture forum\nm9. Fri 11:00-12:00 demo prep",
 "gaps": "g1. Mon 13:25-17:30 (245 min)\ng2. Wed 13:25-17:30 (245 min)\ng3. Fri 13:25-17:30 (245 min)",
 "free_total": "12.25 hours in 3 stretches of 120+ minutes",
 "move_options": "m2. lunch (Mon, Tue, Wed, Thu, Fri 12:30-13:15): moving it adds 575 min of focus-length free time\nm5. design review (Tue 15:00-16:00): moving it adds 245 min of focus-length free time\nm8. architecture forum (Thu 14:30-15:30): moving it adds 245 min of focus-length free time"
}

Reply:

{
 "task": "focus",
 "verdict": "short",
 "blocks": [
  {
   "gap": "g1",
   "start": "13:30",
   "end": "16:30",
   "title": "Learning the new data platform",
   "why": "The Mon 13:25 to 17:30 stretch is 245 minutes, enough for a dedicated focus block."
  },
  {
   "gap": "g2",
   "start": "13:30",
   "end": "16:30",
   "title": "Learning the new data platform",
   "why": "The Wed 13:25 to 17:30 stretch is 245 minutes, enough for a dedicated focus block."
  },
  {
   "gap": "g3",
   "start": "13:30",
   "end": "16:30",
   "title": "Learning the new data platform",
   "why": "The Fri 13:25 to 17:30 stretch is 245 minutes, enough for a dedicated focus block."
  }
 ],
 "move": [
  {
   "meeting": "m5",
   "why": "Moving the Tue 15:00 to 16:00 design review would add 245 minutes of focus-length free time."
  },
  {
   "meeting": "m8",
   "why": "Moving the Thu 14:30 to 15:30 architecture forum would add 245 minutes of focus-length free time."
  }
 ],
 "status_line": "Protected focus time for learning the new data platform.",
 "note": "The blocks give 9 hours against the 12-hour target, and moving the design review and the architecture forum would each open 245 more minutes."
}

The blocks the page keeps (ReplyKit.trusted), 9 hours against a target of 12:

The verdict: short - "The week is too full"; it follows the blocks' total (9 hours against 12).

Worth moving (the trusted suggestions):

On each block: "Protected focus time for learning the new data platform."

The page's check (ReplyKit.check): kept.

No notes.

Afternoons in the studio

The form: Drawing the illustrations for the picture book; 6 hours a week in blocks of at least 60 minutes, latest stretches first; working hours 10:00-18:00, Mon-Fri. The meetings as typed:

Mon, Wed 10:00-11:30 client check-ins
daily 13:00-14:00 lunch
Fri 15:00-17:00 invoicing and admin

The meetings as the page reads them, the free stretches it finds (26.25 hours in 9 stretches of 60+ minutes) and the move options (WeekKit.buildInput):

m1. Mon, Wed 10:00-11:30 client check-ins
m2. Mon, Tue, Wed, Thu, Fri 13:00-14:00 lunch
m3. Fri 15:00-17:00 invoicing and admin
g1. Mon 11:40-12:50 (70 min)
g2. Mon 14:10-18:00 (230 min)
g3. Tue 10:00-12:50 (170 min)
g4. Tue 14:10-18:00 (230 min)
g5. Wed 11:40-12:50 (70 min)
g6. Wed 14:10-18:00 (230 min)
g7. Thu 10:00-12:50 (170 min)
g8. Thu 14:10-18:00 (230 min)
g9. Fri 10:00-12:50 (170 min)
m2. lunch (Mon, Tue, Wed, Thu, Fri 13:00-14:00): moving it adds 440 min of focus-length free time
m3. invoicing and admin (Fri 15:00-17:00): moving it adds 230 min of focus-length free time
m1. client check-ins (Mon, Wed 10:00-11:30): moving it adds 200 min of focus-length free time

The free fill, placed with no run (WeekKit.freeFill):

Mon 15:00-18:00  Focus time
Tue 15:00-18:00  Focus time

Input:

{
 "task": "focus",
 "goal": "Drawing the illustrations for the picture book",
 "target_hours": "6",
 "min_block": "60",
 "prefer": "afternoon",
 "workdays": "Mon, Tue, Wed, Thu, Fri",
 "hours": "10:00-18:00",
 "meetings": "m1. Mon, Wed 10:00-11:30 client check-ins\nm2. Mon, Tue, Wed, Thu, Fri 13:00-14:00 lunch\nm3. Fri 15:00-17:00 invoicing and admin",
 "gaps": "g1. Mon 11:40-12:50 (70 min)\ng2. Mon 14:10-18:00 (230 min)\ng3. Tue 10:00-12:50 (170 min)\ng4. Tue 14:10-18:00 (230 min)\ng5. Wed 11:40-12:50 (70 min)\ng6. Wed 14:10-18:00 (230 min)\ng7. Thu 10:00-12:50 (170 min)\ng8. Thu 14:10-18:00 (230 min)\ng9. Fri 10:00-12:50 (170 min)",
 "free_total": "26.25 hours in 9 stretches of 60+ minutes",
 "move_options": "m2. lunch (Mon, Tue, Wed, Thu, Fri 13:00-14:00): moving it adds 440 min of focus-length free time\nm3. invoicing and admin (Fri 15:00-17:00): moving it adds 230 min of focus-length free time\nm1. client check-ins (Mon, Wed 10:00-11:30): moving it adds 200 min of focus-length free time"
}

Reply:

{
 "task": "focus",
 "verdict": "met",
 "blocks": [
  {
   "gap": "g2",
   "start": "14:15",
   "end": "17:15",
   "title": "Drawing the picture book illustrations",
   "why": "Monday's 14:10 to 18:00 stretch is 230 minutes long, enough for a solid block."
  },
  {
   "gap": "g4",
   "start": "14:15",
   "end": "17:15",
   "title": "Illustrations for the picture book",
   "why": "Tuesday's 14:10 to 18:00 stretch is 230 minutes long, plenty for another block."
  }
 ],
 "move": [],
 "status_line": "Protected focus time for drawing the illustrations for the picture book.",
 "note": "These blocks give 6 hours of focus time, matching the target."
}

The blocks the page keeps (ReplyKit.trusted), 6 hours against a target of 6:

The verdict: met - "Target met"; it follows the blocks' total (6 hours against 6).

Worth moving: nothing - the week holds the target.

On each block: "Protected focus time for drawing the illustrations for the picture book."

The page's check (ReplyKit.check): kept.

No notes.

The calendar file for the first sample

The opening lines of what WeekKit.toICS(blocks, {from: "", note: status_line, uid: "example"}) writes for "Writing a quarterly report" above, made on Monday 2 November 2026 with no start date: the calendar header and the first of its 5 weekly events (lines end in CRLF; long lines fold at 75 octets).

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SkillSafe//Deep Hours//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:deep-hours-example-0@skillsafe.ai
DTSTAMP:20261102T090000Z
DTSTART:20261102T100000
DTEND:20261102T121500
RRULE:FREQ=WEEKLY;BYDAY=MO
SUMMARY:Writing the quarterly report
DESCRIPTION:This is protected focus time for writing the quarterly report.
TRANSP:OPAQUE
CLASS:PUBLIC
END:VEVENT

Build the input yourself

Load the engine in node (global.window = global; require("./weekkit.js"); require("./replykit.js")), then:

const K = WeekKit, R = ReplyKit;
const form = K.form({ goal: "Writing the quarterly report", meetings: "daily 9:30-9:45 standup\nWed 11:00-12:00 team review",
  start: "09:00", end: "12:30", week: "mon-fri", target: "6", minBlock: "90", prefer: "morning", from: "" });
// form.meetings is the week as read: [{id, days, start, end, title}], times in minutes from midnight
const built = K.buildInput(form);     // built.ok is false, with built.note, until the goal is named and a stretch fits
// built.gaps: the free stretches [{id, day, start, end, len}]; built.moves: the move options
K.freeFill(form, built.gaps)          // the free fill, no run needed
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)      // {blocks, dropped, total}: only blocks inside a stretch the page sent
// R.plain(t.blocks)                       // "Mon 10:00-12:30  Writing the quarterly report" ...
// R.check(reply, built)                   // {status: kept or flagged, notes}
// K.toICS(t.blocks, { from: "2026-11-02", note: reply.status_line })  // the calendar file

Form keys: goal (what the focus time is for), meetings (one recurring meeting per line), start and end (the working hours), week (mon-fri, sun-thu or mon-sat), target (hours a week, 1 to 30), minBlock (60, 90 or 120), prefer (morning, afternoon or any) and from (the calendar file's first date as YYYY-MM-DD, or empty for today).

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. 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://deep-hours.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"}
}

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":"deep-hours"}' | jq -r '.data.token')

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

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": "focus", "goal": "Writing the quarterly report", "target_hours": "6", "min_block": "90", "prefer": "morning", "workdays": "Mon, Tue, Wed, Thu, Fri", "hours": "09:00-12:30", "meetings": "m1. Mon, Tue, Wed, Thu, Fri 09:30-09:45 standup\nm2. Wed 11:00-12:00 team review", "gaps": "g1. Mon 09:55-12:30 (155 min)\ng2. Tue 09:55-12:30 (155 min)\ng3. Thu 09:55-12:30 (155 min)\ng4. Fri 09:55-12:30 (155 min)", "free_total": "10.25 hours in 4 stretches of 90+ minutes", "move_options": "m1. standup (Mon, Tue, Wed, Thu, Fri 09:30-09:45): moving it adds 330 min of focus-length free time\nm2. team review (Wed 11:00-12:00): moving it adds 155 min of focus-length free time"}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

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 deep-hours:<task>:<inputHash>:a<attempt> (the one task is focus), 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="deep-hours:focus:$(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) \(.blocks | length)", (.blocks[] | "\(.start)-\(.end) \(.title)")' reply.json

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: deep-hours:focus:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json