Run Issue Press from your own code
This week's links in, a newsletter issue out. Issue Press takes the links a curator saved, each with their notes, and a short brief (the newsletter's name, the issue date, the format, the audience, the tone, the length), and holds the pile to the newsletter-generation skill's source evaluation: recency against the issue date and the format's window, no duplicate stories, balanced sources, nothing paywalled summarised as if the reader could open it, every item with something to say. The model never types a link and never adds a fact: it refers to items by their pile number n, every fact and number comes from that item's title or notes, and the caller (or the page) inserts the clean link from the pile.
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the pile for free - it parses bare and Markdown links with a title and notes under each, canonicalises every link (tracking parameters, mobile and AMP hosts, fragments and trailing slashes removed), catches duplicate stories by link and by title, flags paywalled publications, social posts, insecure links, items older than the format's window and links with no notes, detects a week dominated by one source, lays the items into the format's sections, checks the subject line, preview text and footer, and - for a draft - finds the links that are not in the pile and the numbers no note gives. That is ipscan.js, and its findings are the IP- rules. The run either writes the issue from the pile ("write") or edits a draft issue against the pile and the skill's quality checklist ("edit"); the page then holds the reply to the pile (ipreply.js): every item used once or cut with a reason, no duplicate used, no URL in the text, every number from that item's notes, the format's sections in their counts, paywalled items labelled, three subject lines and a preview of the right length - or, for an edit, all eleven checklist entries, the page's findings failing the matching entries, every quote from the draft and a verdict that matches.
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":"issue-press"}), so no slug header is needed afterwards. Send it as Authorization: Bearer .... The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a field the run needs (task, brief, pile, page) is missing or not a string. Send every field as a string; draft is "" for "write". |
| 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 - "write" (the issue from the pile: three subject_lines to test, a preview, a title, the format's opening as intro, the format's item sections with each item referred to by its pile number n, the format's closing slot as closing, and every unused item under cut with a reason) and "edit" (a draft issue, sent as draft, checked against the pile and the skill's quality checklist: a verdict of ready, revise or rework, a two-sentence summary, all eleven checklist entries, one fix per problem quoted from the draft, and better subject lines). Both lanes send the same fields; draft is "" for "write". A task the model does not know is answered as write, with a caveat saying so.
The input
Every field is a string.
| Field | Required | What |
|---|---|---|
task | yes | "write" or "edit". The model answers that task and only that task. |
brief | yes | the brief, one line each, built by ipscan.js (IpScan.briefText(scan.brief)): Newsletter:, Tagline:, Issue date:, Format:, Audience:, Tone:, Length:, Subject (draft):, Preview (draft):, Footer facts:. See the brief format. |
pile | yes | the items as numbered entries with clean links, built by ipscan.js (IpScan.pileText(scan)) - not the pile as pasted. The numbers are what a "write" reply refers to. See the pile format. |
draft | edit only | the draft issue as written, trimmed (the page wants at least 80 words; the pile and the draft at most 40,000 characters each). Send "" for "write". |
page | yes | the page's reading of the same pile and brief, built by ipscan.js (IpScan.pageFacts(scan, task), via buildInput) - see the page format. |
retry_note | no | present only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise. |
The brief format
brief is ten lines. The format is named in words (Weekly roundup, Daily digest, Deep-dive, Industry briefing) and the length carries its word range; a field nobody gave is -. The brief of the "energy industry briefing" sample (every newsletter, link and note in the samples is invented):
Newsletter: Grid Notes
Tagline: The week in electricity markets
Issue date: 2026-10-30
Format: Industry briefing
Audience: executive
Tone: professional-analytical
Length: medium (700-1400 words)
Subject (draft): Interconnector delays, a record negative-price week and a new capacity auction
Preview (draft): Seven things that moved the market, and the one number to remember.
Footer facts: Grid Notes is published by Fenwick Energy Research, 3 Coal Exchange, Cardiff CF10 5FQ. Unsubscribe: reply STOP.
The pile format
pile is what a paid run sends instead of the pasted text: one entry per link the page read (at most 40), numbered in the order they were pasted, with the clean link and what the page found about it. Duplicates stay in the pile, marked, so a reply can cut them by number.
[<n>] <title, or: (no title)>
link: <the clean link>
date: <YYYY-MM-DD> (when the title or the notes give one)
paywalled (when the host is a paywalled publication)
duplicate of [<n>] (when the page found the same story earlier in the pile)
notes: <the curator's notes, or: (none)>
Three of the eight entries the page sends for the briefing sample. The curator pasted https://www.ofgem.gov.uk/press-release/...; the pile carries the clean link without the www.:
[1] Ofgem confirms cap-and-floor window 3 decisions (2026-10-27)
link: https://ofgem.gov.uk/press-release/ofgem-confirms-cap-and-floor-window-3-decisions
date: 2026-10-27
notes: Two interconnectors approved in principle, one deferred to 2027 pending grid connection date. Combined 2.8 GW.
[2] ENTSO-E: negative price hours hit record in Q3 (2026-10-28)
link: https://entsoe.eu/news/2026/10/28/negative-prices-q3
date: 2026-10-28
notes: Germany had 412 negative-price hours in Q3, Netherlands 389. Solar-driven, midday. Up from 301 and 266 a year earlier.
[5] UK battery revenues fall as more capacity arrives (2026-10-26)
link: https://ft.com/content/battery-storage-revenues-uk-2026
date: 2026-10-26
paywalled
notes: FT piece: average battery revenue per MW down about a third year on year as fleet passes 6 GW. Paywalled.
The page format
page is the page's own reading, written as plain text. The page builds it in the browser with ipscan.js (IpScan.buildInput(state, task) runs IpScan.scan(state) and calls IpScan.pageFacts(scan, task)), and the model works from what it prints: the format's sections and their item counts, the page's placement to start from, and every error and warn rule id to answer; the page then holds the reply to the same scan. So an API client should send page built the same way - from ipscan.js run under node (Build the input yourself; the same brief, pile and draft give the same text as the browser), or the page's own text for the same pile - not an empty string and not a reading of your own.
Issue date <YYYY-MM-DD>; <format> (covers the last <n> days; <min>-<max> items); <n> usable items of <n>.
Your own words: `intro` is the <opening>; `closing` is <closing slot>.
Item sections of this format (id | heading | items min-max | the page's placement):
- <id> | <heading> | <min>-<max> | [<n>] [<n>] ... (or: -)
Not placed: [<n>] duplicate, [<n>] no room, ... . (when there are any)
Sources: <host> <count>, ... . (or: none)
The draft: <n> words, <n> links; uses items <n>, ...[; links not in the pile: <url>, ...][; numbers no note gives: <x>, ...]. (edit only)
Findings (rule | severity | item | where | text):
- <rule id> | <error|warn|info> | <item|-> | <where|-> | <title>[ (note)]
An empty findings list gets the single line - none. A real one - the page the browser sends for the briefing sample on the "write" lane, in full:
Issue date 2026-10-30; Industry briefing (covers the last 14 days; 5-16 items); 8 usable items of 8.
Your own words: `intro` is the executive summary; `closing` is outlook.
Item sections of this format (id | heading | items min-max | the page's placement):
- market | Market developments | 1-3 | [5]
- companies | Company news & moves | 1-4 | [4]
- product | Product & technology | 0-3 | [8]
- policy | Regulatory & policy | 0-3 | [3] [1] [7]
- data | Data & metrics | 0-2 | [6] [2]
Sources: ofgem.gov.uk 1, entsoe.eu 1, gov.uk 1, reuters.com 1, ft.com 1, nationalgrideso.com 1, ec.europa.eu 1, tesla.com 1.
Findings (rule | severity | item | where | text):
- IP-SUBJECT | warn | - | Subject | Subject line that will hurt opens (78 characters)
- IP-PAYWALL | warn | 5 | ft.com | A paywalled source (UK battery revenues fall as more capacity arrives (2026-10-26))
The formats
The four formats of the source skill (FORMATS in ipscan.js), with the window the page checks recency against, the item count it expects, the opening and closing the reply writes as intro and closing, and the item sections a "write" reply fills, by id, with their minimum and maximum item counts. The length is short (350-700 words), medium (700-1400) or long (1400-2600).
| Format | Window | Items | intro / closing | Sections (id: heading, min-max) |
|---|---|---|---|---|
Weekly roundup (weekly) | 10 days | 6-14 | Editor's note / One more thing | top: Top stories, 2-3; trends: Trends & analysis, 1-2; quick: Quick bites, 3-6; tools: Tools & resources, 0-3 |
Daily digest (daily) | 2 days | 3-7 | Good morning / What to watch | top: Top story, 1-1; quick: Quick hits, 2-5; stat: Stat of the day, 0-1 |
Deep-dive (deepdive) | 45 days | 3-10 | Introduction & context / What's next | why: Why it matters, 0-2; developments: Key developments, 2-5; perspectives: Expert perspectives, 0-3; reading: Further reading, 0-4 |
Industry briefing (briefing) | 14 days | 5-16 | Executive summary / Outlook | market: Market developments, 1-3; companies: Company news & moves, 1-4; product: Product & technology, 0-3; policy: Regulatory & policy, 0-3; data: Data & metrics, 0-2 |
The page's findings
Rule ids the page uses, with their severity and area (RULES in ipscan.js). IP-UNSOURCED, IP-NUMBER, IP-DRAFTDUP and IP-MISSING come only with a draft (the "edit" lane); IP-FOOTER only when footer facts are given.
| Rule | Severity | Area | Title |
|---|---|---|---|
IP-DUP | warn | pile | The same story twice |
IP-PAYWALL | warn | sources | A paywalled source |
IP-STALE | warn | pile | Older than this format's window |
IP-SOURCE | warn | sources | One source dominates the issue |
IP-NONOTE | warn | pile | A link with nothing written about it |
IP-COUNT | warn | pile | Too few or too many items for the format |
IP-SUBJECT | warn | Subject line that will hurt opens | |
IP-FOOTER | warn | Footer missing what email law asks for | |
IP-NODATE | info | pile | Items without a date |
IP-TRACK | info | pile | Tracking parameters removed |
IP-SOCIAL | info | sources | A social post as the source |
IP-HTTP | info | pile | An insecure http:// link |
IP-PREVIEW | info | Preview text missing or the wrong length | |
IP-UNSOURCED | error | draft | A link in the draft that is not in the pile |
IP-NUMBER | warn | draft | A number in the draft that no note gives |
IP-MISSING | info | draft | Pile items the draft does not use |
IP-DRAFTDUP | warn | draft | The draft links the same story twice |
A point the page cannot see (a story that is really two items, a note that contradicts its title, a missing primary source) comes back as a finding with the rule NEW.
The standard the model applies
The source skill - @bytedance/newsletter-generation (bytedance/deer-flow, MIT) - is the only standard:
- Formats: a daily digest, a weekly roundup, a deep-dive or an industry briefing, each with its own sections (see the formats).
- Curation: recency, authority (primary sources over reposts), uniqueness, relevance, actionability, diversity. Quality beats quantity: five excellent items beat fifteen mediocre ones. Never summarise paywalled content as if the reader could open it.
- Top stories: a clear, benefit-led headline (not clickbait), a one or two sentence hook, the key facts, and why it matters to this reader. Quick items: a bold headline and two or three sentences with one takeaway. Analysis: observation, context, implication.
- Writing: scannable, lead with the most interesting angle, concise, accurate (every fact sourced, every number verified), attributed, human - a knowledgeable friend, not a press release - and matched to the audience.
- The quality checklist: every factual claim has a source; links work; dates use the real issue date; content is current; no duplicate stories; consistent formatting; balanced coverage; the right length; an engaging opening; a clear closing; proofread.
House rules
- The pile is the only source. Every fact, name and number comes from an item's title or notes; nothing the model knows from elsewhere is added. An item whose notes are too thin to say something true and useful is cut, with the reason.
- The model never writes a URL. A
"write"reply refers to items by their numbern; the clean link is inserted from the pile afterwards (see Putting the links in). - An item marked
duplicate of [n]is not used; the original is. A paywalled item is said to be paywalled when it is used ("paywalled at the FT"). A stale item is used only when the notes say why it matters now, or as further reading. - Subject lines: 30 to 60 characters, no shouting, no spam-trigger words, no exclamation marks. Preview text: 40 to 130 characters that add to the subject.
- Every
errorandwarnfinding in the page is answered by its rule id inprescan, one entry per distinct id (callagreeordisagree, with anote);infoids need no entry. Points the page cannot see go infindingswith the ruleNEW. - The reply is ONE JSON object and nothing else - no prose around it, no Markdown fences.
The replies
Both lanes carry task, subject_lines, preview, findings, prescan and caveats; "write" adds title, intro, sections, closing and cut, and "edit" adds verdict, summary, checklist and fixes. The shapes below are what the page reads after IpReply.normalize(): a missing list comes back empty, every value becomes a trimmed string, at most five subject lines are kept, a finding with no rule becomes NEW and an unknown severity becomes medium, a call other than disagree becomes agree, a verdict other than ready, revise or rework becomes "", an item's n is the first whole number in it ("[5]" reads as 5), Markdown bold and heading marks are stripped from headlines, a checklist pass is true only for true, "true" or "pass", and entries with nothing in them are dropped (an item with no number or no text, a cut with no number, a fix with no problem and no fix, a finding with no title and no why). A write is usable when a section has an item; an edit when it has a verdict and a checklist. The page also refuses a reply that answers the other task.
write
The issue, written from the pile's notes.
subject_lines- three to test, each a different angle;preview- one.title- the issue's title (usually the newsletter's name).intro- the format's opening (editor's note, good-morning line, context, executive summary): two to four sentences naming what is inside.sections- the format's item sections in order, by the page's sectionid, each with aheading, an optional one-linetextanditems. Each item hasn(the pile number),headline(plain text),body, andwhyfor lead stories and analysis (empty otherwise). Each section keeps its item count; each item appears once.closing- the format's closing slot (one more thing, what to watch, what's next, outlook), built from the pile.cut- every item not used, duplicates included, each withnandwhy.findings(points the page cannot see:ruleNEW,severityhigh/medium/low,title,where,why,fix);prescan(rule,call,note);caveats(strings).
{
"task": "write",
"subject_lines": ["", "", ""],
"preview": "",
"title": "",
"intro": "",
"sections": [{"id": "", "heading": "", "text": "", "items": [{"n": 1, "headline": "", "body": "", "why": ""}]}],
"closing": "",
"cut": [{"n": 0, "why": ""}],
"findings": [{"rule": "NEW", "severity": "medium", "title": "", "where": "", "why": "", "fix": ""}],
"prescan": [{"rule": "", "call": "agree", "note": ""}],
"caveats": [""]
}
edit
The draft issue, against the pile and the checklist.
verdict-reworkwhen the page found an error (a link that is not in the pile); otherwiserevisewhen any checklist entry fails; otherwiseready.summary- two sentences: the verdict and the first thing to fix.checklist- all eleven entries, each withid,pass(true or false) andnote. The ids (CHECKSinipreply.js):sourced,links,dates,current,duplicates,formatting,balance,length,opening,closing,proofread. A link not in the pile failssourcedandlinks; a number no note gives failssourced; a story linked twice failsduplicates.fixes- one per problem, each withquote(copied exactly from the draft, or its subject line or preview),problemandfix(the replacement text where it helps); every link not in the pile and every number no note gives has one.subject_lines(three better ones, if the draft's could be better) andpreview.findings,prescan,caveats- as for"write".
{
"task": "edit",
"verdict": "revise",
"summary": "",
"checklist": [{"id": "sourced", "pass": true, "note": ""}],
"fixes": [{"quote": "", "problem": "", "fix": ""}],
"subject_lines": [""],
"preview": "",
"findings": [],
"prescan": [{"rule": "", "call": "agree", "note": ""}],
"caveats": [""]
}
The page holds either reply to the pile with IpReply.check() and shows each miss next to the reply as a note. For both lanes it wants every error and warn rule id answered in prescan or findings; no rule id the page did not report other than NEW; and subject lines of 30 to 60 characters with no spam words or shouting, and a preview of 40 to 130 characters. For "write" it also wants three subject lines; every n in the pile; no item used twice and no duplicate used; every live item used or cut with a reason; no link typed in the text; every number in an item from that item's title or notes (and every number elsewhere from some item - small whole numbers and years are not claims); only the format's section ids, each within its item count; every paywalled item said to be paywalled; a length within the brief's range (with a quarter's leeway each way); and an intro. For "edit" it wants all eleven checklist entries; sourced and links failing when the draft links outside the pile, sourced failing with a number no note gives, duplicates failing with a story linked twice and current failing when the draft uses stale items; every quote found in the draft; a fix for every link not in the pile and every number no note gives; and the verdict the checklist and the page's findings call for (rework with an error, revise with a failing entry or a warning, ready otherwise).
Putting the links in
A "write" reply carries no links: each item names its pile number, and the link is the link: line of that entry in pile. The page renders the issue with IpScan.issueMarkdown(scan, reply) and an email-ready file with IpScan.issueHtml(scan, reply, subject, preview) (one 600-pixel table, inline styles, no scripts, no images); both look each item up by n and add its clean link, labelled with the host and "(paywalled)" where the pile says so. Lead stories get a heading, the body, "Why it matters" and a source line; quick sections (quick, bites, hits, tools, resources, reading, company, product, policy) get one bullet each. Part of what issueMarkdown makes of the briefing sample's reply below:
## Market developments
More storage, thinner spreads.
### UK battery revenues down about a third as the fleet passes 6 GW
The FT reports (paywalled at the FT) that average battery revenue per MW has fallen by roughly a third year on year as installed UK capacity passes 6 GW. More batteries are chasing the same price spreads and balancing services.
**Why it matters:** Business cases built on last year's revenue stack need re-testing; the merchant upside for storage is compressing as capacity arrives.
Source: [ft.com (paywalled)](https://ft.com/content/battery-storage-revenues-uk-2026)
## Regulatory & policy
Three decisions that set investment timelines.
- **Capacity Market T-4: 43.2 GW target, price cap unchanged** - Government has published the T-4 auction parameters: a 43.2 GW target for delivery in 2030/31, with the price cap held at 75 per kW-year. The auction runs in March. [gov.uk](https://gov.uk/government/news/capacity-market-t4-auction-parameters-2026)
...
Without ipscan.js, read the numbers and links out of pile yourself: every entry starts with [n] at the start of a line and its next line is link: <url>. Step 5 does exactly that in each language.
Reading the reply
The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in ipreply.js, and closes a stream that stopped mid-object). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'write' - no prose, no code fences." and a new attempt number in the key (see step 5). Step 5 prints, for "write", the subject lines and preview, the title and intro, every section with its items and the link each one gets from the pile, the closing and the cuts; for "edit", the verdict and summary, the checklist and the fixes; then for both lanes the page's calls, the further points and the caveats.
Write, then edit
The two lanes chain. Once a "write" reply has been rendered (links in) and polished by hand, its Markdown becomes the draft of an "edit" with the same brief and pile: the edit then catches a link added since that is not in the pile, a number no note gives, a story that now appears twice, or a section that has drifted from the format. An edit needs a page built for the edit lane, which adds the draft's line (the items it uses, its links not in the pile, its numbers no note gives) and the draft's findings.
Worked samples
Two of the page's own samples, with the real saved replies it shows for them (the inputs are byte for byte what the page sends). Every newsletter, link, place and note is invented.
Sample 1 - write: an energy industry briefing
Eight saved links for an executive briefing on electricity markets: regulator decisions, market data, a company deal, a product launch and one paywalled FT piece. The draft subject line is 78 characters. The page lays the items into the briefing's five sections and reports two warnings (IP-SUBJECT for the subject, IP-PAYWALL for item 5). Input - brief and page are exactly what the page sends; pile is shortened here (the ... stands for entries left out - send the whole text, shown in part under the pile format):
{
"task": "write",
"brief": "Newsletter: Grid Notes\nTagline: The week in electricity markets\nIssue date: 2026-10-30\nFormat: Industry briefing\nAudience: executive\nTone: professional-analytical\nLength: medium (700-1400 words)\nSubject (draft): Interconnector delays, a record negative-price week and a new capacity auction\nPreview (draft): Seven things that moved the market, and the one number to remember.\nFooter facts: Grid Notes is published by Fenwick Energy Research, 3 Coal Exchange, Cardiff CF10 5FQ. Unsubscribe: reply STOP.",
"pile": "[1] Ofgem confirms cap-and-floor window 3 decisions (2026-10-27)\n link: https://ofgem.gov.uk/press-release/ofgem-confirms-cap-and-floor-window-3-decisions\n date: 2026-10-27\n notes: Two interconnectors approved in principle, one deferred to 2027 pending grid connection date. Combined 2.8 GW.\n[2] ENTSO-E: negative price hours hit record in Q3 (2026-10-28)\n link: https://entsoe.eu/news/2026/10/28/negative-prices-q3\n date: 2026-10-28\n notes: Germany had 412 negative-price hours in Q3, Netherlands 389. Solar-driven, midday. Up from 301 and 266 a year earlier.\n...\n[5] UK battery revenues fall as more capacity arrives (2026-10-26)\n link: https://ft.com/content/battery-storage-revenues-uk-2026\n date: 2026-10-26\n paywalled\n notes: FT piece: average battery revenue per MW down about a third year on year as fleet passes 6 GW. Paywalled.\n...\n[8] Tesla launches Megapack 3 (2026-10-21)\n link: https://tesla.com/blog/megapack-3-launch\n date: 2026-10-21\n notes: Bigger cells, they claim 28% more energy per container and simpler site work. Shipping from Houston in 2027.",
"draft": "",
"page": "Issue date 2026-10-30; Industry briefing (covers the last 14 days; 5-16 items); 8 usable items of 8.\nYour own words: `intro` is the executive summary; `closing` is outlook.\nItem sections of this format (id | heading | items min-max | the page's placement):\n- market | Market developments | 1-3 | [5]\n- companies | Company news & moves | 1-4 | [4]\n- product | Product & technology | 0-3 | [8]\n- policy | Regulatory & policy | 0-3 | [3] [1] [7]\n- data | Data & metrics | 0-2 | [6] [2]\nSources: ofgem.gov.uk 1, entsoe.eu 1, gov.uk 1, reuters.com 1, ft.com 1, nationalgrideso.com 1, ec.europa.eu 1, tesla.com 1.\nFindings (rule | severity | item | where | text):\n- IP-SUBJECT | warn | - | Subject | Subject line that will hurt opens (78 characters)\n- IP-PAYWALL | warn | 5 | ft.com | A paywalled source (UK battery revenues fall as more capacity arrives (2026-10-26))"
}
Reply - the saved reply, abbreviated (each /* ... */ comment stands for entries left out, so the block is not valid JSON as shown):
{
"task": "write",
"subject_lines": [
"Record negative prices and a 43.2 GW capacity target",
"Ofgem backs two interconnectors; one slips to 2027",
"Winter margin widens to 6.1 GW as battery revenue falls"
],
"preview": "Germany logged 412 negative-price hours in Q3, Brussels settles grid financing, and Orsted rotates out of Hornsea 3.",
"title": "Grid Notes",
"intro": "Solar is reshaping prices faster than the market can absorb it: Germany logged 412 negative-price hours in Q3 and the Netherlands 389, both well up on a year earlier, while UK battery revenue per MW has fallen by about a third as the fleet passes 6 GW. /* ... 3 more sentences ... */",
"sections": [
{"id":"market","heading":"Market developments","text":"More storage, thinner spreads.","items":[
{"n":5,"headline":"UK battery revenues down about a third as the fleet passes 6 GW","body":"The FT reports (paywalled at the FT) that average battery revenue per MW has fallen by roughly a third year on year as installed UK capacity passes 6 GW. More batteries are chasing the same price spreads and balancing services.","why":"Business cases built on last year's revenue stack need re-testing; the merchant upside for storage is compressing as capacity arrives."}]},
{"id":"product","heading":"Product & technology","text":"","items":[
{"n":8,"headline":"Tesla launches Megapack 3, shipping from Houston in 2027","body":"Tesla says Megapack 3 uses bigger cells and delivers 28% more energy per container, with simpler site work. These are the company's own claims. Shipping from Houston starts in 2027.","why":"If the claims hold, more energy per container and lighter site work lower installed cost per MWh, which matters as revenue per MW falls (see the battery item above)."}]},
{"id":"data","heading":"Data & metrics","text":"","items":[
{"n":6,"headline":"NESO winter outlook: 6.1 GW de-rated margin","body":"NESO's base case de-rated margin for winter 2026/27 is 6.1 GW, or 9.5%, up from 4.4 GW last winter. The demand flexibility service is not needed as a default this year.","why":"A wider margin points to fewer tight-system days and less scarcity pricing this winter."},
{"n":2,"headline":"Negative-price hours hit a Q3 record","body":"ENTSO-E data show Germany had 412 negative-price hours in Q3 and the Netherlands 389, up from 301 and 266 a year earlier. The hours are solar-driven and fall at midday.","why":"Midday negative prices erode solar capture rates and are the opportunity storage and flexible demand are built to capture."}]}
/* ... 2 more: companies [4], policy [3] [1] [7] ... */
],
"closing": "Outlook: the next fixed date is the T-4 auction in March, against a 43.2 GW target and an unchanged price cap. /* ... 2 more sentences ... */",
"cut": [],
"findings": [
{"rule":"NEW","severity":"medium","title":"The 2.8 GW figure is ambiguous","where":"Item [1] notes","why":"The notes say 'Combined 2.8 GW' after naming two approved and one deferred interconnector; it is unclear whether 2.8 GW covers the two approved projects or all three.","fix":"Check the Ofgem release and state which projects the 2.8 GW covers; the issue currently says 'the projects total 2.8 GW combined'."},
{"rule":"NEW","severity":"low","title":"Megapack 3 figures are vendor claims","where":"Item [8]","why":"The 28% figure comes from Tesla's own launch blog with no independent check.","fix":"Keep the attribution ('Tesla says') and avoid presenting the figure as verified."}
/* ... 2 more: Price cap currency not stated, Orsted sale sourced to Reuters ... */
],
"prescan": [
{"rule":"IP-SUBJECT","call":"agree","note":"The draft subject is 78 characters; the three replacements are 50 to 55 characters."},
{"rule":"IP-PAYWALL","call":"agree","note":"Item [5] is used but labelled 'paywalled at the FT' and summarised only from the curator's notes."}
],
"caveats": [
"The draft preview promised seven things; the issue uses all eight pile items, so the new preview drops the count.",
"Item [6] links to nationalgrideso.com but is titled NESO; the issue calls it NESO as the title does."
]
}
All eight items are placed once, in the page's sections and within their counts, so cut is empty; every number in an item (412, 389, 6.1 GW, 9.5%, 28%) is in that item's notes; the paywalled FT item says so; no link appears anywhere in the text - issueMarkdown adds each one from the pile (see Putting the links in). The ambiguous 2.8 GW and the vendor's own 28% go to NEW findings, and both warning ids are answered in prescan. The page's reply check finds nothing to note.
Sample 2 - edit: a daily city digest
A four-item daily digest for a small harbour town, and the draft the editor wrote from it. The draft links a mooring map that is not in the pile, calls the fare rise "a 15% jump" (no note gives 15%, and 3.20 to 3.60 is 12.5%) and runs the ferry fares twice through a utm_ variant of the same link. The page finds one error (IP-UNSOURCED), two warnings (IP-NUMBER, IP-DRAFTDUP) and one info finding, so the verdict it expects is rework. Input - every field exactly what the page sends:
{
"task": "edit",
"brief": "Newsletter: Harbor Morning\nTagline: Port Ellis, before your coffee goes cold\nIssue date: 2026-10-30\nFormat: Daily digest\nAudience: general\nTone: conversational-professional\nLength: short (350-700 words)\nSubject (draft): Ferry fares, a new bakery and Saturday's road closures\nPreview (draft): Plus the council vote nobody is talking about.\nFooter facts: Harbor Morning, 14 Quay Street, Port Ellis PE1 2AB. Unsubscribe at harbormorning.news/unsub.",
"pile": "[1] Council confirms ferry fares for 2027 (2026-10-29)\n link: https://portellis.gov/news/ferry-fares-2027\n date: 2026-10-29\n notes: Adult single goes from 3.20 to 3.60 from 1 January. Under-16s stay free. Monthly pass up 8% to 54.\n[2] Marina Road closed Saturday for resurfacing (2026-10-29)\n link: https://portellisgazette.co/2026/10/29/marina-road-closure\n date: 2026-10-29\n notes: Closed 7am-6pm Saturday 1 November between the bus station and the lifeboat house. Buses divert via Hill Street.\n[3] Crumb & Anchor opens on Fore Street (2026-10-28)\n link: https://instagram.com/p/DFb7xQ2oPqR\n date: 2026-10-28\n notes: New bakery in the old chandlery. Opening weekend: free coffee with any pastry before 9.\n[4] Planning committee approves 40 flats at the old boatyard (2026-10-28)\n link: https://portellis.gov/meetings/planning-2026-10-28\n date: 2026-10-28\n notes: Approved 6-3. 12 of the 40 are affordable. Objectors cited flood risk; the report says the ground floor is raised 1.2m.",
"draft": "# Harbor Morning\n*Port Ellis, before your coffee goes cold - October 30, 2026*\n\nGood morning. Fares are going up, a road is closing and there is a new place to get a croissant.\n\n## Top story\n### Ferry fares rise in January\nThe council has confirmed next year's fares: the adult single rises from 3.20 to 3.60 on 1 January, a 15% jump, and the monthly pass goes up to 54. Under-16s still ride free. [portellis.gov](https://portellis.gov/news/ferry-fares-2027)\n\n## Quick hits\n- **Marina Road closed Saturday** - 7am to 6pm between the bus station and the lifeboat house; buses go via Hill Street. [Gazette](https://www.portellisgazette.co/2026/10/29/marina-road-closure)\n- **New bakery on Fore Street** - Crumb & Anchor opens in the old chandlery, with free coffee before 9 on opening weekend. [Instagram](https://www.instagram.com/p/DFb7xQ2oPqR/)\n- **Ferry fares, again** - the monthly pass rises 8%. [portellis.gov](https://portellis.gov/news/ferry-fares-2027?utm_source=x)\n\n## What to watch\nThe planning committee approved 40 flats at the old boatyard, 12 of them affordable, despite flood worries. The developer says building starts in spring. [Planning](https://portellis.gov/meetings/planning-2026-10-28)\n\nAlso worth a look: the harbour authority's new mooring map. [map](https://harbourauthority.example/moorings-2027)\n\n---\nHarbor Morning, 14 Quay Street, Port Ellis PE1 2AB. Unsubscribe at harbormorning.news/unsub.",
"page": "Issue date 2026-10-30; Daily digest (covers the last 2 days; 3-7 items); 4 usable items of 4.\nYour own words: `intro` is the good morning; `closing` is what to watch.\nItem sections of this format (id | heading | items min-max | the page's placement):\n- top | Top story | 1-1 | [4]\n- quick | Quick hits | 2-5 | [1] [3]\n- stat | Stat of the day | 0-1 | [2]\nSources: portellis.gov 2, portellisgazette.co 1, instagram.com 1.\nThe draft: 191 words, 6 links; uses items 1, 2, 3, 4; links not in the pile: https://harbourauthority.example/moorings-2027; numbers no note gives: 15%.\nFindings (rule | severity | item | where | text):\n- IP-UNSOURCED | error | - | draft | A link in the draft that is not in the pile (https://harbourauthority.example/moorings-2027)\n- IP-NUMBER | warn | - | draft | A number in the draft that no note gives (15%)\n- IP-DRAFTDUP | warn | 1 | item 1 | The draft links the same story twice (linked more than once)\n- IP-SOCIAL | info | 3 | instagram.com | A social post as the source (Crumb & Anchor opens on Fore Street (2026-10-28))"
}
Reply - the saved reply, abbreviated as above:
{
"task": "edit",
"verdict": "rework",
"summary": "Rework: the draft links a harbour authority mooring map that is not in the pile, and it adds a 15% figure and a spring start date that no note gives. Start by deleting the mooring-map line, then fold the duplicate ferry quick hit into the top story and drop the 15%.",
"checklist": [
{"id":"sourced","pass":false,"note":"Three claims have no source in the pile: the mooring map, the 15% (no note gives it, and 3.20 to 3.60 is 12.5% anyway) and 'The developer says building starts in spring.' 'Croissant' is not in the notes either; item [3] says pastry."},
{"id":"links","pass":false,"note":"https://harbourauthority.example/moorings-2027 is not in the pile. The other links are variants of pile links (www., trailing slash, ?utm_source=x); refer to items by number and let the page insert the clean link."},
{"id":"dates","pass":true,"note":"The dateline uses the real issue date, October 30, 2026."},
{"id":"duplicates","pass":false,"note":"Item [1] (ferry fares) runs twice: as the top story and again as the 'Ferry fares, again' quick hit, through a utm variant of the same link."},
{"id":"length","pass":false,"note":"191 words against a 350-700 word brief. The notes have room to grow it honestly: the 6-3 vote, the flood-risk objection and the raised ground floor for [4], free under-16 travel for [1], the bus diversion for [2]."},
/* ... 6 more: current, formatting, balance, opening and proofread pass; closing fails ... */
],
"fixes": [
{"quote":"Also worth a look: the harbour authority's new mooring map. [map](https://harbourauthority.example/moorings-2027)","problem":"A link and a claim that are not in the pile.","fix":"Delete the line."},
{"quote":"a 15% jump","problem":"No note gives 15%, and it is wrong: 3.20 to 3.60 is a 12.5% rise.","fix":"The council has confirmed next year's fares: the adult single rises from 3.20 to 3.60 on 1 January, and the monthly pass goes up 8% to 54. Under-16s still ride free. [1]"},
{"quote":"The developer says building starts in spring.","problem":"No note says this; item [4] mentions no developer and no start date.","fix":"The vote was 6-3. Objectors cited flood risk; the committee report says the ground floor is raised 1.2m. [4]"},
/* ... 7 more: the duplicate ferry quick hit, the croissant opening, the www. and Instagram link variants, What to watch, the subject line, the preview ... */
],
"subject_lines": [
"Ferry fares rise, Marina Road shuts Saturday",
"40 boatyard flats approved, ferry fares up in January",
"A new bakery, a closed road and dearer ferries"
],
"preview": "Marina Road shuts 7am-6pm Saturday, and 40 boatyard flats got the nod 6-3.",
"findings": [
{"rule":"NEW","severity":"high","title":"The 15% is not just unsourced but wrong","where":"Top story","why":"Item [1] gives 3.20 to 3.60, which is a 12.5% rise; the draft's 15% misstates the pile's own numbers.","fix":"Drop the percentage for the single fare and keep the 3.20 to 3.60 figures."},
/* ... 1 more: What to watch has nothing to watch ... */
],
"prescan": [
{"rule":"IP-UNSOURCED","call":"agree","note":"The harbour authority mooring map is not in the pile; delete the line."},
{"rule":"IP-NUMBER","call":"agree","note":"No note gives 15%, and 3.20 to 3.60 is 12.5%; remove it."},
{"rule":"IP-DRAFTDUP","call":"agree","note":"Item [1] runs as the top story and again as 'Ferry fares, again'; merge the 8% into the top story."}
],
"caveats": [
"Gazette and Instagram links are www./trailing-slash variants of pile links; treated as the pile items, not as outside links.",
/* ... 2 more ... */
]
}
The verdict matches the page's error; all eleven checklist entries are there, with sourced, links and duplicates failing as the page's findings require; every quote is copied from the draft or its subject line and preview; the out-of-pile link and the 15% each have a fix; and the three distinct error and warning ids are answered once each in prescan (the info id IP-SOCIAL needs no entry). The page's reply check finds nothing to note.
Build the input yourself
The scan and the reply checker are plain scripts that run under node (global.window = global; require("./ipscan.js"); require("./ipreply.js"), from the app at https://issue-press.skillsafe.ai/ipscan.js and /ipreply.js). The state is the form: format is weekly, daily, deepdive or briefing, length is short, medium or long, and pile is the pasted text - a bare URL or a Markdown link per item, a title and notes under it, a blank line between items. The same state gives the same brief, pile and page as the browser - both samples above reproduce byte for byte:
const S = IpScan, R = IpReply;
const st = { name: "Grid Notes", tagline: "The week in electricity markets", date: "2026-10-30", format: "briefing",
audience: "executive", tone: "professional-analytical", length: "medium", subject: "...", preview: "...", footer: "...",
pile: fs.readFileSync("links.txt", "utf8"), draft: "" };
const b = S.buildInput(st, "write"); // or "edit" with st.draft; b.ok is false (with b.note) when it cannot run
console.log(b.scan.layout.map(x => x.id + " " + x.items), b.scan.findings.map(f => f.id)); // [ 'market 5', ... ] [ 'IP-SUBJECT', 'IP-PAYWALL' ]
const wire = S.toWire(b.input); // task, brief, pile, draft, page - all strings; POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "write");
// R.check(reply, b) // {notes, warn}: unanswered findings, items used twice or neither used nor cut, typed links, numbers no note gives
// S.issueMarkdown(b.scan, reply) // the issue as Markdown, each link inserted from the pile by n
// S.issueHtml(b.scan, reply, reply.subject_lines[0], reply.preview) // the email-ready HTML file
Without node, the page itself shows the same reading: paste the brief and the links at issue-press.skillsafe.ai, and the placement and findings it shows come from the same scan as the page text it sends. 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 in place of YOUR_TOKEN, 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://issue-press.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://issue-press.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"]
// issue-press.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://issue-press.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://issue-press.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
}
// IssuePress.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 IssuePress {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://issue-press.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://issue-press.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, 443, 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://issue-press.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://issue-press.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":"issue-press"}' | 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": "issue-press"})["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: "issue-press" })).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": "issue-press"}, 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", "issue-press"), 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: "issue-press" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "issue-press"])["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 = "issue-press" }))!["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. Save a worked-sample input above as input.json (task "write" with the brief, the whole pile, draft "" and the page, or "edit" with the draft too, each with the page the page builds for it) to follow along.
# input.json: the run input object - {"task": "write", "brief": "Newsletter: ...", "pile": "[1] ...\n link: ...", "draft": "", "page": "Issue date ...\nItem sections ...\nFindings ..."}
# - 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": "write", "brief": "...", "pile": "...", "draft": "", "page": "Issue date ..."}
# - 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: "write" or "edit", brief, pile, draft, page} (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 issue-press:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a write and an edit are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. Each run is metered on gpt-terra; the job reports charged_credits, and truncated is true when the available balance cut the reply short. The code below parses the JSON object out of the output, reads each item's link out of pile by its number, and prints the subject lines and preview; then, for "write", the title, the intro, each section with its items, why each matters and the link the pile gives it, the closing and the cuts; for "edit", the verdict, the summary, the checklist and the fixes; then for both lanes the page's calls, the further points and the caveats.
KEY="issue-press:$(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 --slurpfile inp input.json '
($inp[0].pile | [scan("\\[(\\d+)\\][^\\n]*\\n link: (\\S+)")] | map({key: .[0], value: .[1]}) | from_entries) as $link
| (.subject_lines[]? | "subject: \(.)"), "preview: \(.preview // "")",
(if .task == "edit" then
"VERDICT: \(.verdict)", .summary,
(.checklist[]? | "\(if .pass == true then "PASS" else "FIX " end) \(.id): \(.note)"),
(.fixes[]? | "! \"\(.quote)\" - \(.problem) -> \(.fix)")
else
"# \(.title)", .intro, "",
(.sections[]? | "## \(.heading)", (.text // "" | select(. != "")),
(.items[]? | "[\(.n)] \(.headline) - \(.body)",
((.why // "") | select(. != "") | " why it matters: \(.)"),
" \($link[.n | tostring] // "(not in the pile)")"), ""),
"closing: \(.closing)",
(.cut[]? | "cut [\(.n)]: \(.why)")
end),
(.prescan[]? | "\(.rule) \(.call): \(.note)"),
(.findings[]? | "\(.rule) [\(.severity)] \(.title) - \(.fix)"),
(.caveats[]? | "caveat: \(.)")' reply.json
import hashlib, re, time
key = "issue-press:" + 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)
out = j["output"]["output"]
reply = json.loads(out[out.index("{"):out.rindex("}") + 1]) # the reply as one JSON object
link = dict(re.findall(r"\[(\d+)\][^\n]*\n link: (\S+)", inp["pile"])) # pile number -> clean link
print(j["charged_credits"], j.get("truncated"))
for s in reply.get("subject_lines", []):
print("subject:", s)
print("preview:", reply.get("preview", ""))
if reply.get("task") == "edit":
print("VERDICT:", reply.get("verdict", ""))
print(reply.get("summary", ""))
for c in reply.get("checklist", []):
print("PASS" if c.get("pass") is True else "FIX ", c.get("id", "") + ":", c.get("note", ""))
for x in reply.get("fixes", []):
print('! "' + x.get("quote", "") + '" - ' + x.get("problem", "") + " -> " + x.get("fix", ""))
else:
print("# " + reply.get("title", ""), reply.get("intro", ""), "", sep="\n")
for s in reply.get("sections", []):
print("## " + s.get("heading", ""))
if s.get("text"):
print(s["text"])
for it in s.get("items", []):
print(f"[{it.get('n')}] {it.get('headline', '')} - {it.get('body', '')}")
if it.get("why"):
print(" why it matters:", it["why"])
print(" " + link.get(str(it.get("n")), "(not in the pile)"))
print()
print("closing:", reply.get("closing", ""))
for c in reply.get("cut", []):
print(f"cut [{c.get('n')}]: {c.get('why', '')}")
for p in reply.get("prescan", []):
print(f"{p.get('rule', '')} {p.get('call', '')}: {p.get('note', '')}")
for f in reply.get("findings", []):
print(f"{f.get('rule', 'NEW')} [{f.get('severity', '')}] {f.get('title', '')} - {f.get('fix', '')}")
for c in reply.get("caveats", []):
print("caveat:", c)
import { createHash } from "node:crypto";
const key = `issue-press:${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 out = j.output.output;
const reply = JSON.parse(out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1));
const link = Object.fromEntries([...input.pile.matchAll(/\[(\d+)\][^\n]*\n link: (\S+)/g)].map((m) => [m[1], m[2]])); // pile number -> clean link
console.log(j.charged_credits, j.truncated);
const each = (v, f) => (Array.isArray(v) ? v : []).forEach(f);
each(reply.subject_lines, (s) => console.log("subject:", s));
console.log("preview:", reply.preview || "");
if (reply.task === "edit") {
console.log("VERDICT:", reply.verdict || "");
console.log(reply.summary || "");
each(reply.checklist, (c) => console.log(`${c.pass === true ? "PASS" : "FIX "} ${c.id}: ${c.note || ""}`));
each(reply.fixes, (x) => console.log(`! "${x.quote || ""}" - ${x.problem || ""} -> ${x.fix || ""}`));
} else {
console.log(`# ${reply.title || ""}\n${reply.intro || ""}\n`);
each(reply.sections, (s) => {
console.log("## " + (s.heading || ""));
if (s.text) console.log(s.text);
each(s.items, (it) => {
console.log(`[${it.n}] ${it.headline || ""} - ${it.body || ""}`);
if (it.why) console.log(" why it matters:", it.why);
console.log(" " + (link[String(it.n)] || "(not in the pile)"));
});
console.log("");
});
console.log("closing:", reply.closing || "");
each(reply.cut, (c) => console.log(`cut [${c.n}]: ${c.why || ""}`));
}
each(reply.prescan, (p) => console.log(`${p.rule} ${p.call}: ${p.note || ""}`));
each(reply.findings, (f) => console.log(`${f.rule || "NEW"} [${f.severity || ""}] ${f.title || ""} - ${f.fix || ""}`));
each(reply.caveats, (c) => console.log("caveat:", c));
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "regexp", "strings", "time" to the imports
key := "issue-press:" + 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 }
}
out := j["output"].(map[string]any)["output"].(string)
var reply map[string]any
json.Unmarshal([]byte(out[strings.Index(out, "{"):strings.LastIndex(out, "}")+1]), &reply)
link := map[string]string{} // pile number -> clean link
for _, m := range regexp.MustCompile(`\[(\d+)\][^\n]*\n link: (\S+)`).FindAllStringSubmatch(input["pile"].(string), -1) { link[m[1]] = m[2] }
fmt.Println(j["charged_credits"], j["truncated"])
list := func(v any) []any { a, _ := v.([]any); return a }
obj := func(v any) map[string]any { o, _ := v.(map[string]any); if o == nil { o = map[string]any{} }; return o }
str := func(o map[string]any, k string) string { s, _ := o[k].(string); return s }
for _, s := range list(reply["subject_lines"]) { fmt.Println("subject:", s) }
fmt.Println("preview:", str(reply, "preview"))
if reply["task"] == "edit" {
fmt.Printf("VERDICT: %v\n%v\n", reply["verdict"], reply["summary"])
for _, x := range list(reply["checklist"]) { c := obj(x); mark := "FIX "; if c["pass"] == true { mark = "PASS" }; fmt.Printf("%s %s: %s\n", mark, str(c, "id"), str(c, "note")) }
for _, x := range list(reply["fixes"]) { f := obj(x); fmt.Printf("! %q - %s -> %s\n", str(f, "quote"), str(f, "problem"), str(f, "fix")) }
} else {
fmt.Printf("# %s\n%s\n\n", str(reply, "title"), str(reply, "intro"))
for _, x := range list(reply["sections"]) {
s := obj(x); fmt.Println("## " + str(s, "heading"))
if t := str(s, "text"); t != "" { fmt.Println(t) }
for _, y := range list(s["items"]) {
it := obj(y); n := fmt.Sprint(it["n"])
fmt.Printf("[%s] %s - %s\n", n, str(it, "headline"), str(it, "body"))
if w := str(it, "why"); w != "" { fmt.Println(" why it matters:", w) }
if l, ok := link[n]; ok { fmt.Println(" " + l) } else { fmt.Println(" (not in the pile)") }
}
fmt.Println()
}
fmt.Println("closing:", str(reply, "closing"))
for _, x := range list(reply["cut"]) { c := obj(x); fmt.Printf("cut [%v]: %s\n", c["n"], str(c, "why")) }
}
for _, x := range list(reply["prescan"]) { p := obj(x); fmt.Printf("%s %s: %s\n", str(p, "rule"), str(p, "call"), str(p, "note")) }
for _, x := range list(reply["findings"]) { f := obj(x); fmt.Printf("%s [%s] %s - %s\n", str(f, "rule"), str(f, "severity"), str(f, "title"), str(f, "fix")) }
for _, c := range list(reply["caveats"]) { fmt.Println("caveat:", c) }
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "issue-press:" + 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()));
String out = j.get("output").get("output").asText();
JsonNode reply = M.readTree(out.substring(out.indexOf('{'), out.lastIndexOf('}') + 1));
Map<String, String> link = new HashMap<>(); // pile number -> clean link
var lm = java.util.regex.Pattern.compile("\\[(\\d+)\\][^\\n]*\\n link: (\\S+)").matcher((String) input.get("pile"));
while (lm.find()) link.put(lm.group(1), lm.group(2));
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
for (JsonNode s : reply.path("subject_lines")) System.out.println("subject: " + s.asText());
System.out.println("preview: " + reply.path("preview").asText());
if ("edit".equals(reply.path("task").asText())) {
System.out.println("VERDICT: " + reply.path("verdict").asText() + "\n" + reply.path("summary").asText());
for (JsonNode c : reply.path("checklist")) System.out.println((c.path("pass").asBoolean(false) ? "PASS " : "FIX ") + c.path("id").asText() + ": " + c.path("note").asText());
for (JsonNode x : reply.path("fixes")) System.out.println("! \"" + x.path("quote").asText() + "\" - " + x.path("problem").asText() + " -> " + x.path("fix").asText());
} else {
System.out.println("# " + reply.path("title").asText() + "\n" + reply.path("intro").asText() + "\n");
for (JsonNode s : reply.path("sections")) {
System.out.println("## " + s.path("heading").asText());
if (!s.path("text").asText().isEmpty()) System.out.println(s.path("text").asText());
for (JsonNode it : s.path("items")) {
String n = it.path("n").asText();
System.out.println("[" + n + "] " + it.path("headline").asText() + " - " + it.path("body").asText());
if (!it.path("why").asText().isEmpty()) System.out.println(" why it matters: " + it.path("why").asText());
System.out.println(" " + link.getOrDefault(n, "(not in the pile)"));
}
System.out.println();
}
System.out.println("closing: " + reply.path("closing").asText());
for (JsonNode c : reply.path("cut")) System.out.println("cut [" + c.path("n").asText() + "]: " + c.path("why").asText());
}
for (JsonNode p : reply.path("prescan")) System.out.println(p.path("rule").asText() + " " + p.path("call").asText() + ": " + p.path("note").asText());
for (JsonNode f : reply.path("findings")) System.out.println(f.path("rule").asText() + " [" + f.path("severity").asText() + "] " + f.path("title").asText() + " - " + f.path("fix").asText());
for (JsonNode cv : reply.path("caveats")) System.out.println("caveat: " + cv.asText());
require "digest"
key = "issue-press:#{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
out = j["output"]["output"]
reply = JSON.parse(out[out.index("{")..out.rindex("}")])
link = input["pile"].scan(/\[(\d+)\][^\n]*\n link: (\S+)/).to_h # pile number -> clean link
puts [j["charged_credits"], j["truncated"]].inspect
(reply["subject_lines"] || []).each { |s| puts "subject: #{s}" }
puts "preview: #{reply["preview"]}"
if reply["task"] == "edit"
puts "VERDICT: #{reply["verdict"]}", reply["summary"]
(reply["checklist"] || []).each { |c| puts "#{c["pass"] == true ? "PASS" : "FIX "} #{c["id"]}: #{c["note"]}" }
(reply["fixes"] || []).each { |x| puts "! \"#{x["quote"]}\" - #{x["problem"]} -> #{x["fix"]}" }
else
puts "# #{reply["title"]}", reply["intro"], ""
(reply["sections"] || []).each do |s|
puts "## #{s["heading"]}"
puts s["text"] unless s["text"].to_s.empty?
(s["items"] || []).each do |it|
puts "[#{it["n"]}] #{it["headline"]} - #{it["body"]}"
puts " why it matters: #{it["why"]}" unless it["why"].to_s.empty?
puts " " + link.fetch(it["n"].to_s, "(not in the pile)")
end
puts ""
end
puts "closing: #{reply["closing"]}"
(reply["cut"] || []).each { |c| puts "cut [#{c["n"]}]: #{c["why"]}" }
end
(reply["prescan"] || []).each { |p| puts "#{p["rule"]} #{p["call"]}: #{p["note"]}" }
(reply["findings"] || []).each { |f| puts "#{f["rule"]} [#{f["severity"]}] #{f["title"]} - #{f["fix"]}" }
(reply["caveats"] || []).each { |c| puts "caveat: #{c}" }
$key = "issue-press:" . $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"]));
$out = $j["output"]["output"];
$from = strpos($out, "{");
$reply = json_decode(substr($out, $from, strrpos($out, "}") - $from + 1), true);
preg_match_all('/\[(\d+)\][^\n]*\n link: (\S+)/', $input["pile"], $m);
$link = array_combine($m[1], $m[2]); // pile number -> clean link
echo $j["charged_credits"], "\n";
foreach ($reply["subject_lines"] ?? [] as $s) echo "subject: ", $s, "\n";
echo "preview: ", $reply["preview"] ?? "", "\n";
if (($reply["task"] ?? "") === "edit") {
echo "VERDICT: ", $reply["verdict"] ?? "", "\n", $reply["summary"] ?? "", "\n";
foreach ($reply["checklist"] ?? [] as $c) echo ($c["pass"] ?? false) === true ? "PASS " : "FIX ", $c["id"] ?? "", ": ", $c["note"] ?? "", "\n";
foreach ($reply["fixes"] ?? [] as $x) echo "! \"", $x["quote"] ?? "", "\" - ", $x["problem"] ?? "", " -> ", $x["fix"] ?? "", "\n";
} else {
echo "# ", $reply["title"] ?? "", "\n", $reply["intro"] ?? "", "\n\n";
foreach ($reply["sections"] ?? [] as $s) {
echo "## ", $s["heading"] ?? "", "\n";
if (!empty($s["text"])) echo $s["text"], "\n";
foreach ($s["items"] ?? [] as $it) {
$n = (string) ($it["n"] ?? "");
echo "[", $n, "] ", $it["headline"] ?? "", " - ", $it["body"] ?? "", "\n";
if (!empty($it["why"])) echo " why it matters: ", $it["why"], "\n";
echo " ", $link[$n] ?? "(not in the pile)", "\n";
}
echo "\n";
}
echo "closing: ", $reply["closing"] ?? "", "\n";
foreach ($reply["cut"] ?? [] as $c) echo "cut [", $c["n"] ?? "", "]: ", $c["why"] ?? "", "\n";
}
foreach ($reply["prescan"] ?? [] as $p) echo $p["rule"] ?? "", " ", $p["call"] ?? "", ": ", $p["note"] ?? "", "\n";
foreach ($reply["findings"] ?? [] as $f) echo $f["rule"] ?? "NEW", " [", $f["severity"] ?? "", "] ", $f["title"] ?? "", " - ", $f["fix"] ?? "", "\n";
foreach ($reply["caveats"] ?? [] as $c) echo "caveat: ", $c, "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"issue-press:{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 output = j["output"]!["output"]!.GetValue<string>();
var from = output.IndexOf('{');
var reply = JsonNode.Parse(output.Substring(from, output.LastIndexOf('}') - from + 1))!;
var link = new Dictionary<string, string>(); // pile number -> clean link
foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(input["pile"]!.GetValue<string>(), @"\[(\d+)\][^\n]*\n link: (\S+)"))
link[m.Groups[1].Value] = m.Groups[2].Value;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
JsonArray Arr(JsonNode? n) => n as JsonArray ?? new JsonArray();
string Str(JsonNode? n) => n?.ToString() ?? "";
foreach (var s in Arr(reply["subject_lines"])) Console.WriteLine($"subject: {Str(s)}");
Console.WriteLine($"preview: {Str(reply["preview"])}");
if (Str(reply["task"]) == "edit") {
Console.WriteLine($"VERDICT: {Str(reply["verdict"])}\n{Str(reply["summary"])}");
foreach (var c in Arr(reply["checklist"])) Console.WriteLine($"{(Str(c?["pass"]) == "true" ? "PASS" : "FIX ")} {Str(c?["id"])}: {Str(c?["note"])}");
foreach (var x in Arr(reply["fixes"])) Console.WriteLine($"! \"{Str(x?["quote"])}\" - {Str(x?["problem"])} -> {Str(x?["fix"])}");
} else {
Console.WriteLine($"# {Str(reply["title"])}\n{Str(reply["intro"])}\n");
foreach (var s in Arr(reply["sections"])) {
Console.WriteLine($"## {Str(s?["heading"])}");
if (Str(s?["text"]) != "") Console.WriteLine(Str(s?["text"]));
foreach (var it in Arr(s?["items"])) {
var n = Str(it?["n"]);
Console.WriteLine($"[{n}] {Str(it?["headline"])} - {Str(it?["body"])}");
if (Str(it?["why"]) != "") Console.WriteLine($" why it matters: {Str(it?["why"])}");
Console.WriteLine(" " + (link.TryGetValue(n, out var l) ? l : "(not in the pile)"));
}
Console.WriteLine();
}
Console.WriteLine($"closing: {Str(reply["closing"])}");
foreach (var c in Arr(reply["cut"])) Console.WriteLine($"cut [{Str(c?["n"])}]: {Str(c?["why"])}");
}
foreach (var p in Arr(reply["prescan"])) Console.WriteLine($"{Str(p?["rule"])} {Str(p?["call"])}: {Str(p?["note"])}");
foreach (var f in Arr(reply["findings"])) Console.WriteLine($"{Str(f?["rule"])} [{Str(f?["severity"])}] {Str(f?["title"])} - {Str(f?["fix"])}");
foreach (var cv in Arr(reply["caveats"])) Console.WriteLine($"caveat: {Str(cv)}");
Step 6 - POST /run-stream
The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.
# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: issue-press:$(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 (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": "issue-press:" + 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": `issue-press:${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" 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", "issue-press:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
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", "issue-press:" + M.readTree(rev).path("task").asText() + ":" + 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" => "issue-press:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, 443, 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");
$task = json_decode($rev, true)["task"];
$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: issue-press:$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", $"issue-press:{JsonNode.Parse(rev)!["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);