← Panel Hop
Panel Hop API
Tokens

Drive Panel Hop from your own code

The one metered step - planning a path through one fan convention's programme - is available over HTTP. A natural use is a convention's own schedule app or a group chat bot: your code sends the programme with the attendee's tags and the page's reading, and gets back the sessions to attend in time order with an arrival time for each (queue time before a must-see, the walk between rooms), the sessions dropped with a backup (usually a later repeat), the meal breaks, and a fits / trim / overbooked verdict.

The division of labour first: the page reads and flags; the model plans; the page re-checks the model's path with its own rules. Reading the programme, the tags, the repeats, the day cards and every flag (clash, tight hop, no meal, long day, hard hours) come from hopkit.js, which runs in node as well as in the browser. Its check function then holds the reply to the programme: every session id must be real and used once, every starred session must be on the path or dropped, every quote must appear in the session's line as written, and clashes, walks and meal gaps are re-run on the minutes the path actually occupies (arrive to leave).

Get a token on the token page (step 2 below also shows how to mint a guest token in code).

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": "...", "status": 402, "details": { ... } } }

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

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or task / programme 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 plan is metered and needs a personal token.
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 comes first

Every run sends task. There is one task, plan. It returns title, verdict, summary; path [{session, arrive, leave, says, why}] (every session attended, in time order); drops [{session, says, why, instead}]; breaks [{day, from, to, kind, why}]; tips (0-3).

VerdictMeaning
fitsEvery must-see, want and fixed slot is on the path at its listed time, start to end. Maybes may be dropped.
trimEvery must-see and fixed slot is kept (a later repeat or a short late join still counts), but a want is dropped or moved, or a starred session is joined late or left early.
overbookedAt least one must-see or fixed slot cannot be kept in any form - two must-sees at the same hour with no repeat, or a ticketed slot on top of one.
FieldValuesWhat the page does with it
path[].session, drops[].sessionP1 ... PnSession ids from session_list. An unknown or repeated id is not counted; a starred session in neither list is reported as undecided.
path[].arrive, path[].leaveHH:MM or ""The minutes the path occupies. Arriving before the start is queue time (drawn dashed on the guide); after the start is a late join; leave before the end is an early exit.
drops[].insteada session id on the path, or ""When it names the same title (a repeat), the dropped session counts as still seen.
breaks[].kindmeal, restDrawn on the "Your path" strip; a break that overlaps a path session gets a warning.

The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (HopKit.toWire does this). The schema requires task and programme.

Input fields

FieldTypeNotes
taskstringAlways plan.
todaystringYYYY-MM-DD.
programmestringThe programme as pasted (day | start-end | room | title | tag). Past 9,000 characters, unstarred lines are left out from the middle first; every starred, maybe and repeat line is kept.
factsstring (JSON){con, days, sessions, rooms, must, want, maybe, fixed, walk_min, early_min, meal_min, max_hours, per_day}; per_day is [{day, starred, starred_hours, on_feet, lunch_gap, dinner_gap}].
session_liststring (JSON)[{id, day, start, end, room, title, tag, repeats}]; tag is must, want, maybe, fixed or listed; repeats lists the ids of sessions with the same title. On a programme of more than 60 sessions, only starred, maybe and repeat sessions are listed.
page_readsstring (JSON)[{level, code, at, text}]; level is fix, warn or info; at lists the session ids.
page_verdictstringThe page's own reading: fits, trim or overbooked.
notesstringOptional, the attendee's own comments; past 1,200 characters the rest is cut.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with HopKit.parseReply, then normalize, then check it with check(reply, state), which returns {rows, drops, breaks, missing, an, notes, lost, moved, expect, skipped}: the path in time order with the minutes each stop occupies, the applied drops, the breaks, the starred sessions left undecided, the page's analysis of the path (clashes, hops, meal gaps, long days), the must-sees and fixed slots lost, the sessions moved to a repeat, the verdict the path reads as, the entries not counted, and the notes (ok, info, warn, fix) the page shows under "Checked by the page".

Worked examples

Replies produced against the app's prompt for the sample conventions (made-up conventions, guests and sessions; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the long fields folded.

task: plan - Small comic con, one Saturday - fits

Input:

{
 "task": "plan",
 "today": "2026-10-20",
 "programme": "Saturday\nSat | 10:00-10:30 | Main Stage | Opening: what's new in indie comics | want\nSat | 11:00-12:00 | Room 2 | Inking with brush pens workshop | mu ... (644 characters)",
 "facts": "{\"con\":\"Riverside Comic Fair\",\"days\":[\"Sat\"],\"sessions\":10,\"rooms\":5,\"must\":2,\"want\":2,\"maybe\":1,\"fixed\":1,\"walk_min\":10,\"early_min\":15,\"meal_min\":30,\"max_hours\":10,\"per_day\":[{\"day\":\"Sat\",\"starred\":5,\"starred_hours\":4,\"on_feet\":\"10:00-17:30\",\"lunch_gap\":true,\"dinner_gap\":null}]}",
 "session_list": "[{\"id\":\"P1\",\"day\":\"Sat\",\"start\":\"10:00\",\"end\":\"10:30\",\"room\":\"Main Stage\",\"title\":\"Opening: what's new in indie comics\",\"tag\":\"want\",\"repeats\":[]},{\"id\":\"P2\",\"day\":\"Sat\",\"start\":\"11:00\",\"end\":\"12:00\",\"room\":\"Room 2\",\"title\":\"Inking with brush pens workshop\",\"tag\":\"must\",\"repeats\":[]},\"... 8 more\"]",
 "page_reads": "[]",
 "page_verdict": "fits",
 "notes": "First time at this fair. I have a signing ticket for 17:00."
}

Reply:

{
 "task": "plan",
 "title": "A full day built around Ana Brindlecove",
 "verdict": "fits",
 "summary": "This one's a clean fits: nothing on the starred list clashes, and every hop between rooms leaves enough time to walk over before the next session starts. The inking workshop and the creator spotlight both get their full 15-minute queue, with a free hour around midday between them for lunch. Nothing had to be dropped, not even the zine swap, and the day closes with the ticketed Ana Brindlecove signing attended start to finish.",
 "path": [
  {
   "session": "P1",
   "arrive": "10:00",
   "leave": "",
   "says": "Opening: what's new in indie comics",
   "why": "opens the day, no queue needed"
  },
  {
   "session": "P2",
   "arrive": "10:45",
   "leave": "",
   "says": "Inking with brush pens workshop",
   "why": "walked over and queued 15 minutes for the must-see"
  },
  {
   "session": "P5",
   "arrive": "13:15",
   "leave": "",
   "says": "Creator spotlight: Ana Brindlecove",
   "why": "queued 15 minutes again, after the lunch gap"
  },
  {
   "session": "P6",
   "arrive": "14:45",
   "leave": "",
   "says": "Small-press zine swap",
   "why": "walked straight over from the spotlight, arriving right on time"
  },
  "..."
 ],
 "drops": [],
 "breaks": [
  {
   "day": "Sat",
   "from": "12:10",
   "to": "12:50",
   "kind": "meal",
   "why": "the one real gap in the day, between the inking workshop and the spotlight queue"
  }
 ],
 "tips": [
  "The hops into the zine swap and into Lettering your first page only leave about 5 minutes once you've walked over, so don't linger.",
  "If anything runs long, the zine swap is the one session in the day you could skip without touching a must or fixed slot."
 ]
}

task: plan - Two-day anime con, clashing wants - trim

Input:

{
 "task": "plan",
 "today": "2026-10-20",
 "programme": "Saturday\nSat | 09:30-10:30 | Hall A | Opening ceremony | maybe\nSat | 10:00-11:00 | Room 204 | Drawing chibi characters workshop | want\nSat | 10:30-11: ... (1013 characters)",
 "facts": "{\"con\":\"Harbor Anime Weekend\",\"days\":[\"Sat\",\"Sun\"],\"sessions\":15,\"rooms\":5,\"must\":3,\"want\":6,\"maybe\":3,\"fixed\":1,\"walk_min\":10,\"early_min\":15,\"meal_min\":30,\"max_hours\":10,\"per_day\":[{\"day\":\"Sat\",\"starred\":6,\"starred_hours\":6.8,\"on_feet\":\"10:00-16:30\",\"lunch_gap\":false,\"dinner_gap\":null},\"... 1 more\"]}",
 "session_list": "[{\"id\":\"P1\",\"day\":\"Sat\",\"start\":\"09:30\",\"end\":\"10:30\",\"room\":\"Hall A\",\"title\":\"Opening ceremony\",\"tag\":\"maybe\",\"repeats\":[]},{\"id\":\"P2\",\"day\":\"Sat\",\"start\":\"10:00\",\"end\":\"11:00\",\"room\":\"Room 204\",\"title\":\"Drawing chibi characters workshop\",\"tag\":\"want\",\"repeats\":[]},\"... 13 more\"]",
 "page_reads": "[{\"level\":\"warn\",\"code\":\"clash\",\"at\":[\"P2\",\"P3\"],\"text\":\"Sat 10:30: P2 (want) and P3 (must-see) overlap by 30 min - P10 repeats one of them later.\"},{\"level\":\"warn\",\"code\":\"clash\",\"at\":[\"P4\",\"P5\"],\"text\":\"Sat 12:00: P4 (want) and P5 (must-see) overlap by 30 min.\"},\"... 7 more\"]",
 "page_verdict": "trim",
 "notes": "Going with a friend who only cares about the cosplay events. Happy to skip the opening ceremony."
}

Reply:

{
 "task": "plan",
 "title": "A Trimmed Harbor Anime Weekend",
 "verdict": "trim",
 "summary": "This weekend needs a trim: on Saturday the premiere screening moves to its 18:00 repeat so the chibi workshop keeps its morning slot, and the voice-acting workshop steps aside for the Q&A since it has no repeat of its own. The foam-armour workshop gives way too - it was the day's tightest hop and the only way to open a real lunch gap - leaving the masquerade as the day's cosplay highlight and the opening ceremony skipped as asked. Sunday keeps both the photoshoot and the industry panel exactly on time, drops its two overlapping maybes, and still finds its lunch gap between the panel and the closing awards. Lunch lands at 13:00-13:40 Saturday and 13:30-14:00 Sunday, with the repeat screening rounding out Saturday evening.",
 "path": [
  {
   "session": "P2",
   "arrive": "10:00",
   "leave": "",
   "says": "Drawing chibi characters workshop",
   "why": "kept as planned once the opening ceremony is skipped"
  },
  {
   "session": "P5",
   "arrive": "11:45",
   "leave": "",
   "says": "Voice cast Q&A: Moonlit Bakery",
   "why": "a must beats the clashing workshop; arrives 15 min early to queue"
  },
  {
   "session": "P8",
   "arrive": "15:00",
   "leave": "",
   "says": "Cosplay masquerade",
   "why": "the friend's cosplay pick, runs clear of any clash"
  },
  {
   "session": "P10",
   "arrive": "18:00",
   "leave": "",
   "says": "Premiere screening: Lanterns of Kiri, ep. 1-2",
   "why": "the repeat of the must-see premiere, taken instead so the chibi workshop keeps its slot"
  },
  "..."
 ],
 "drops": [
  {
   "session": "P1",
   "says": "Opening ceremony",
   "why": "overlaps the chibi workshop, and the notes say it's fine to skip",
   "instead": ""
  },
  {
   "session": "P3",
   "says": "Premiere screening: Lanterns of Kiri, ep. 1-2",
   "why": "clashes with the chibi workshop; its 18:00 repeat fits cleanly instead",
   "instead": "P10"
  },
  {
   "session": "P4",
   "says": "Voice acting for beginners",
   "why": "a want gives way to the clashing must-see Q&A, with no repeat of its own",
   "instead": ""
  },
  "..."
 ],
 "breaks": [
  {
   "day": "Sat",
   "from": "13:00",
   "to": "13:40",
   "kind": "meal",
   "why": "the stretch freed once the foam-armour workshop steps aside, ahead of the masquerade"
  },
  {
   "day": "Sun",
   "from": "13:30",
   "to": "14:00",
   "kind": "meal",
   "why": "the gap between the industry panel and the closing awards"
  }
 ],
 "tips": [
  "The walk from the Q&A to the old foam-armour slot was always a tight 0 minutes - one more reason that one gave way.",
  "Catch the premiere at its 18:00 repeat - the 10:30 showing clashes with the chibi workshop.",
  "..."
 ]
}

task: plan - Big-city Saturday, overbooked - overbooked

Input:

{
 "task": "plan",
 "today": "2026-10-20",
 "programme": "Sat | 09:00-10:00 | Hall C | Autograph line: guest artist Mei Tollwick (ticket) | fixed\nSat | 09:30-10:30 | Main Hall | Studio showcase: new season announc ... (937 characters)",
 "facts": "{\"con\":\"Kestrelton Fan Days\",\"days\":[\"Sat\"],\"sessions\":14,\"rooms\":5,\"must\":5,\"want\":4,\"maybe\":1,\"fixed\":2,\"walk_min\":15,\"early_min\":20,\"meal_min\":30,\"max_hours\":10,\"per_day\":[{\"day\":\"Sat\",\"starred\":11,\"starred_hours\":12.5,\"on_feet\":\"09:00-20:00\",\"lunch_gap\":false,\"dinner_gap\":false}]}",
 "session_list": "[{\"id\":\"P1\",\"day\":\"Sat\",\"start\":\"09:00\",\"end\":\"10:00\",\"room\":\"Hall C\",\"title\":\"Autograph line: guest artist Mei Tollwick (ticket)\",\"tag\":\"fixed\",\"repeats\":[]},{\"id\":\"P2\",\"day\":\"Sat\",\"start\":\"09:30\",\"end\":\"10:30\",\"room\":\"Main Hall\",\"title\":\"Studio showcase: new season announcements\",\"tag\":\"must\",\"repeats\":[]},\"... 12 more\"]",
 "page_reads": "[{\"level\":\"fix\",\"code\":\"clash\",\"at\":[\"P1\",\"P2\"],\"text\":\"Sat 09:30: P1 (fixed slot) and P2 (must-see) overlap by 30 min.\"},{\"level\":\"warn\",\"code\":\"clash\",\"at\":[\"P3\",\"P5\"],\"text\":\"Sat 11:00: P3 (want) and P5 (must-see) overlap by 30 min.\"},\"... 13 more\"]",
 "page_verdict": "overbooked",
 "notes": "The autograph line and the photo op are paid tickets. I really want both Ironvale talks."
}

Reply:

{
 "task": "plan",
 "title": "Fixed Tickets Hold, One Ironvale Lost",
 "verdict": "overbooked",
 "summary": "This Saturday is overbooked: the autograph-line ticket runs to 10:00 and the walk afterward can't get you to the season-showcase must-see's start in time, so that one is lost outright, and the two Ironvale talks collide head-on with no repeat to save either - the Director Q&A keeps its slot since it's listed first, and the composer talk goes. Everything else holds together: a short early exit from the Q&A makes the walk to the photo-op ticket on time, the model-kit build-along is dropped to open a real half-hour lunch right after the photo op, and both the cosplay contest and the evening concert still get their full queue time by trimming a few minutes off the cosplay contest's end. The after-party DJ set is dropped to bring the day back under the 10-hour on-your-feet limit, and the retro-trivia maybe loses out to the cosplay contest it overlaps.",
 "path": [
  {
   "session": "P1",
   "arrive": "09:00",
   "leave": "",
   "says": "Autograph line: guest artist Mei Tollwick (ticket)",
   "why": "the fixed ticket that opens the day"
  },
  {
   "session": "P5",
   "arrive": "10:40",
   "leave": "11:45",
   "says": "Director Q&A: Ironvale",
   "why": "full 20-minute queue, then leaves a little early to make the walk to the fixed photo op"
  },
  {
   "session": "P7",
   "arrive": "12:00",
   "leave": "",
   "says": "Photo op with guest cosplayer (ticket)",
   "why": "the other fixed ticket, reached right off the walk"
  },
  {
   "session": "P10",
   "arrive": "13:40",
   "leave": "",
   "says": "Live drawing battle",
   "why": "full 20-minute queue after the lunch gap"
  },
  "..."
 ],
 "drops": [
  {
   "session": "P2",
   "says": "Studio showcase: new season announcements",
   "why": "the fixed autograph line runs to 10:00 and the walk over afterward would miss more than half of this one",
   "instead": ""
  },
  {
   "session": "P6",
   "says": "Composer live talk: the Ironvale soundtrack",
   "why": "ties head-on with the Director Q&A - same tag, no repeat, same start time - so the one listed first in the programme keeps its slot",
   "instead": ""
  },
  {
   "session": "P3",
   "says": "Fan translation ethics debate",
   "why": "overlaps the Director Q&A must-see, which outranks it",
   "instead": ""
  },
  "..."
 ],
 "breaks": [
  {
   "day": "Sat",
   "from": "12:30",
   "to": "13:00",
   "kind": "meal",
   "why": "the only real half-hour gap once the model-kit build-along is dropped"
  }
 ],
 "tips": [
  "The walk from the autograph line to the season showcase is the tightest hop of the day - it's the one that doesn't make it.",
  "Leave the Director Q&A a few minutes before it ends to make the walk to the photo-op ticket on time.",
  "..."
 ]
}

Build the input yourself

Load the script in node (global.window = {}; require("./hopkit.js")), then:

const K = HopKit;
const st = K.state({
  today: "2026-10-20", con: "Harbor Anime Weekend", walk: "10", early: "15", meal: "30", maxh: "10", firstday: "2026-11-14",
  notes: "",
  programme: "Saturday\nSat | 10:30-11:30 | Hall B | Premiere screening | must\nSat | 11:30-12:30 | Room 210 | Voice acting for beginners | want\nSat | 18:00-19:00 | Hall B | Premiere screening |"
});
console.log(st.sessions, st.reads, st.verdict);
const { input } = K.buildInput(st, { today: "2026-10-20" });
const wire = K.toWire(input);           // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// const { rows, drops, breaks, an, notes } = K.check(reply, st);
// K.toIcs(st, K.check(reply, st)) gives a calendar file of the path.

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://panel-hop.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":"panel-hop"}' | 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": "plan", "today": "2026-10-20", "programme": "Sat | 12:00-13:00 | Hall A | Voice cast Q&A | must ...", "facts": "{...}", "session_list": "[...]", "page_reads": "[...]", "page_verdict": "reshuffle"}
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 panel-hop:plan:<inputHash>:a<attempt>, 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="panel-hop:plan:$(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}' 

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