Run Ask Your Sheet from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: you send the question and the schema of your table (names, types, row counts and, optionally, value ranges and a few sample rows); the model returns one SQL query, a chart suggestion and notes; you run the SQL on your own data. The rows themselves are never part of the request. The page does exactly this with its in-browser SQL engine.
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":"ask-your-sheet"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, question, schema) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: 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
task picks the lane. ask turns a question into SQL and a chart. fix repairs SQL that failed, given the error your engine reported - send the failed SQL in context and the message in error. Both lanes reply in the same shape.
The input
Every field is a string. The page builds them with SheetReply.buildInput in sheetreply.js and the schema text with SheetIO.schemaText in sheetio.js.
| Field | What it holds |
|---|---|
task | ask or fix. If it is missing or neither, do ask and set "task": "ask". |
question | the person's question, in any language. |
schema | one block per table: TABLE name -- N rows, then one line per column: the name exactly as SQL must spell it, its type (NUMBER, TEXT, DATE, TIMESTAMP, BOOLEAN) and, when shared, its range or values. |
samples | optional: the first few rows of each table as CSV. They show formats only; they are not the whole table. |
context | optional: the previous question and the SQL that answered it. When present, the new question is a follow-up: build on that SQL (same filters and grouping unless the question changes them). |
error | fix only: the error the page's engine reported for the SQL in context. |
retry_note | optional; if present, your previous reply was unusable - follow it. |
The SQL dialect the reply is written in
- One statement:
WITH ... SELECT .... Read-only. No INSERT, UPDATE, CREATE, PRAGMA or semicolon-separated statements. - Supported: SELECT [DISTINCT], FROM tables and subqueries, INNER / LEFT / RIGHT / FULL / CROSS JOIN with ON or USING, WHERE, GROUP BY (names, positions or ALL), HAVING, QUALIFY, UNION [ALL] / INTERSECT / EXCEPT, ORDER BY with ASC / DESC and NULLS FIRST / LAST, LIMIT, OFFSET, CASE, CAST(x AS INTEGER | DOUBLE | TEXT | DATE | TIMESTAMP | BOOLEAN), x::type, IN (list or subquery), BETWEEN, LIKE, ILIKE, IS [NOT] NULL, uncorrelated scalar subqueries.
- Not supported: correlated subqueries (use a JOIN or a window function instead), PIVOT, recursive CTEs, user variables.
- Names: write every table and column exactly as the schema line spells it. A name shown in double quotes must keep its double quotes. Matching ignores letter case. Give every computed column a short snake_case alias.
- Strings use single quotes.
||concatenates./is always decimal division. Division by zero gives NULL. - Aggregates: COUNT(*), COUNT(x), COUNT(DISTINCT x), SUM, AVG, MIN, MAX, MEDIAN, STDDEV, STDDEV_POP, VARIANCE, MODE, STRING_AGG(x, sep), QUANTILE_CONT(x, p), PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY x), COUNT_IF(cond), ANY_VALUE, and
agg(...) FILTER (WHERE ...). - Window functions with OVER (PARTITION BY ... ORDER BY ... [ROWS BETWEEN n PRECEDING AND CURRENT ROW]): ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST, LAG, LEAD, FIRST_VALUE, LAST_VALUE, and SUM / AVG / COUNT / MIN / MAX as running or moving values. Use QUALIFY or a subquery to filter on them.
- Dates and timestamps are ISO text ('2025-03-01', '2025-03-01 14:30:00'). Use DATE_TRUNC('month', d) (also year, quarter, week, day, hour), STRFTIME(d, '%Y-%m') (%Y %m %d %H %M %S %j %W %a %A %b %B, and %q for quarter), YEAR(d), QUARTER(d), MONTH(d), DAY(d), DAYOFWEEK(d) (0 = Sunday), DAYNAME(d), MONTHNAME(d), EXTRACT(part FROM d), DATE_DIFF('day', start, end), d + INTERVAL '7 days', d - INTERVAL '1 month', LAST_DAY(d), MAKE_DATE(y, m, d), CURRENT_DATE. Comparing a date column with a string literal like '2025-01-01' works.
- Other functions: ROUND(x, n), ABS, CEIL, FLOOR, SQRT, POWER, LN, LOG10, EXP, MOD, GREATEST, LEAST, COALESCE, NULLIF, IF(cond, a, b), LOWER, UPPER, INITCAP, LENGTH, TRIM, SUBSTR(s, start, len), LEFT, RIGHT, REPLACE, INSTR, SPLIT_PART, CONCAT, LPAD, CONTAINS, STARTS_WITH, REGEXP_MATCHES, REGEXP_REPLACE, REGEXP_EXTRACT.
That dialect is a subset that DuckDB and, for most queries, SQLite and PostgreSQL also accept, so you can run the reply in your own database.
How the model writes the query
- Answer exactly the question asked. Choose the grain the question implies (per month, per region, top 10 ...) and return a small, readable result: aggregate rather than list raw rows, unless the question asks for rows. For "top N" add ORDER BY and LIMIT N; for any listing of raw rows add a LIMIT of at most 200.
- Always ORDER BY something meaningful: time ascending for trends, the measure descending for rankings.
- Put the label or time column first and the measures after it, so the result reads as a table and charts naturally.
- Round money and averages to 2 decimals with ROUND. Percentages: compute 100.0 * part / total and alias with a
_pctsuffix. - Exclude NULLs from a ranking measure when a NULL would sort to the top or distort an average, and say so in the assumptions.
- When the question is ambiguous (which date column, revenue before or after discount, which year), pick the most reasonable reading, and state it as an assumption.
- If the schema cannot answer the question (the needed column does not exist), set
"sql": ""and explain incannotwhat is missing and which columns could answer something close. Never invent a column. - When the category list in the schema shows the exact spellings, use them verbatim in WHERE filters; otherwise match text with ILIKE.
The reply
{
"task": "ask",
"title": "Revenue by month, 2025",
"sql": "SELECT ...",
"chart": {"type": "line", "x": "month", "y": ["revenue"], "series": null, "title": "Monthly revenue"},
"explanation": "One or two sentences, in the question's language: what the query computes and how - never the numbers, which you have not seen.",
"assumptions": ["Each assumption you made, one sentence each; empty array when none."],
"followups": ["Two or three natural next questions this data can answer."],
"cannot": ""
}
sqlis a single string; newlines inside it are written as \n. No trailing semicolon is needed.- Never state or guess a result value (a total, a top item, a trend direction) in
title,explanationorassumptions- the page computes and shows the real numbers. - Never use backticks anywhere in the reply.
The fix lane adds fixed: one sentence on what was wrong and what changed. When the data cannot answer the question, sql is empty and cannot says why.
The chart field
chart.type is one of: bar (categories, vertical), hbar (long labels or more than 12 categories), line (a trend over time or an ordered number), area (a cumulative or stacked-feeling trend), scatter (two numbers per row), pie (shares of one whole, at most 8 slices, no negatives), number (a single-row result of one to four headline numbers), table (no sensible chart). chart.x is the result column for the axis or slices, chart.y an array of result number columns, chart.series an optional result text column to split lines or bars by (at most 10 values), chart.title a short title. Every name must be a column alias of your SELECT, spelled exactly.
Worked examples - every lane, every sample
These are the page's free samples. Each input is exactly what the page sends; each reply is the saved answer the page replays; the result line is what the page's engine returns when it runs the SQL on the sample data.
Coffee chain sales - Revenue by month and store (ask)
Input:
{
"task": "ask",
"question": "What is total revenue by month, split by store?",
"schema": "TABLE coffee_sales -- 1741 rows\n order_id NUMBER -- range 10001 to 11741, mean 10871\n order_date DATE -- from 2025-01-01 to 2025-12-31\n store TEXT -- 4 values: 'Downtown', 'University', 'Harbor', 'Airport'\n category TEXT -- 5 values: 'Espresso', 'Pastry', 'Brew', 'Tea', 'Merch'\n item TEXT -- 18 distinct values\n qty NUMBER -- range 1 to 3, mean 1.628\n unit_price NUMBER -- range 2.9 to 18.6, mean 4.638\n revenue NUMBER -- range 2.9 to 18.6, mean 7.263\n payment TEXT -- 3 values: 'Card', 'App', 'Cash'\n loyalty_member BOOLEAN -- true/false",
"samples": "-- coffee_sales: first 5 of 1741 rows\norder_id,order_date,store,category,item,qty,unit_price,revenue,payment,loyalty_member\n10001,2025-01-01,Downtown,Espresso,Latte,2,4.6,9.2,Card,true\n10002,2025-01-01,University,Merch,Beans 250g,1,12.5,12.5,Cash,false\n10003,2025-01-01,Harbor,Espresso,Mocha,1,5.1,5.1,Cash,true\n10004,2025-01-01,Harbor,Pastry,Blueberry Muffin,1,3.3,3.3,Card,true\n10005,2025-01-01,Airport,Pastry,Croissant,3,4.2,12.6,Card,true"
}
Reply:
{
"task": "ask",
"title": "Revenue by month and store, 2025",
"sql": "SELECT DATE_TRUNC('month', order_date) AS month,\n store,\n ROUND(SUM(revenue), 2) AS revenue\nFROM coffee_sales\nGROUP BY 1, 2\nORDER BY month, store",
"chart": {
"type": "line",
"x": "month",
"y": [
"revenue"
],
"series": "store",
"title": "Monthly revenue by store"
},
"explanation": "Adds up the revenue of every order line per calendar month and store, one row per month and store, ordered by month.",
"assumptions": [
"Revenue means the revenue column as recorded (quantity times unit price), with no adjustment for refunds.",
"Months are calendar months of order_date."
],
"followups": [
"Which store grew the most between the first and second half of the year?",
"What is the average order value per store each month?",
"How does weekend revenue compare with weekday revenue for each store?"
],
"cannot": ""
}
Result: 48 rows, columns month, store, revenue; first rows [["2025-01-01", "Airport", 257.5], ["2025-01-01", "Downtown", 487.1], ["2025-01-01", "Harbor", 237.6]]
Coffee chain sales - Top 10 items and their share (ask)
Input:
{
"task": "ask",
"question": "Which 10 items bring in the most revenue, and what share of all revenue is each?",
"schema": "TABLE coffee_sales -- 1741 rows\n order_id NUMBER -- range 10001 to 11741, mean 10871\n order_date DATE -- from 2025-01-01 to 2025-12-31\n store TEXT -- 4 values: 'Downtown', 'University', 'Harbor', 'Airport'\n category TEXT -- 5 values: 'Espresso', 'Pastry', 'Brew', 'Tea', 'Merch'\n item TEXT -- 18 distinct values\n qty NUMBER -- range 1 to 3, mean 1.628\n unit_price NUMBER -- range 2.9 to 18.6, mean 4.638\n revenue NUMBER -- range 2.9 to 18.6, mean 7.263\n payment TEXT -- 3 values: 'Card', 'App', 'Cash'\n loyalty_member BOOLEAN -- true/false",
"samples": "-- coffee_sales: first 5 of 1741 rows\norder_id,order_date,store,category,item,qty,unit_price,revenue,payment,loyalty_member\n10001,2025-01-01,Downtown,Espresso,Latte,2,4.6,9.2,Card,true\n10002,2025-01-01,University,Merch,Beans 250g,1,12.5,12.5,Cash,false\n10003,2025-01-01,Harbor,Espresso,Mocha,1,5.1,5.1,Cash,true\n10004,2025-01-01,Harbor,Pastry,Blueberry Muffin,1,3.3,3.3,Card,true\n10005,2025-01-01,Airport,Pastry,Croissant,3,4.2,12.6,Card,true"
}
Reply:
{
"task": "ask",
"title": "Top 10 items by revenue and their share of the total",
"sql": "SELECT item,\n category,\n ROUND(SUM(revenue), 2) AS revenue,\n ROUND(100.0 * SUM(revenue) / SUM(SUM(revenue)) OVER (), 2) AS share_pct\nFROM coffee_sales\nGROUP BY item, category\nORDER BY revenue DESC\nLIMIT 10",
"chart": {
"type": "hbar",
"x": "item",
"y": [
"revenue"
],
"series": null,
"title": "Top 10 items by revenue"
},
"explanation": "Totals revenue per item, computes each item's percentage of revenue across all items before the top 10 are taken, then keeps the ten largest.",
"assumptions": [
"The share is of all revenue in the table, not only of the top 10."
],
"followups": [
"Which category earns the most per order line?",
"How does the top item's revenue change month by month?",
"Which items sell most often in quantity rather than revenue?"
],
"cannot": ""
}
Result: 10 rows, columns item, category, revenue, share_pct; first rows [["Cappuccino", "Espresso", 1391.4, 11], ["Mocha", "Espresso", 1257, 9.94], ["Latte", "Espresso", 1165.4, 9.22]]
Company roster - Median vs average salary (ask)
Input:
{
"task": "ask",
"question": "What are the median and average salary in each department?",
"schema": "TABLE employees -- 240 rows\n employee_id TEXT -- 240 distinct values\n department TEXT -- 6 values: 'Engineering', 'Sales', 'Support', 'Marketing', 'Operations', 'Finance'\n level TEXT -- 5 values: 'L2', 'L1', 'L3', 'L4', 'L5'\n hire_date DATE -- from 2011-05-02 to 2025-08-24\n salary NUMBER -- range 47000 to 193000, mean 101331.25\n office TEXT -- 4 values: 'Lisbon', 'Toronto', 'Remote', 'Singapore'\n performance_rating NUMBER -- range 2 to 5, mean 3.133\n manager BOOLEAN -- true/false",
"samples": "-- employees: first 5 of 240 rows\nemployee_id,department,level,hire_date,salary,office,performance_rating,manager\nE1000,Marketing,L2,2020-06-21,86000,Toronto,3,false\nE1001,Sales,L3,2019-06-04,120500,Remote,3,false\nE1002,Finance,L1,2024-01-28,76500,Lisbon,2,false\nE1003,Engineering,L1,2024-05-20,99000,Toronto,4,false\nE1004,Engineering,L4,2024-06-25,155500,Lisbon,4,true"
}
Reply:
{
"task": "ask",
"title": "Median and average salary by department",
"sql": "SELECT department,\n COUNT(*) AS employees,\n MEDIAN(salary) AS median_salary,\n ROUND(AVG(salary), 2) AS avg_salary\nFROM employees\nGROUP BY department\nORDER BY median_salary DESC",
"chart": {
"type": "bar",
"x": "department",
"y": [
"median_salary",
"avg_salary"
],
"series": null,
"title": "Median vs average salary by department"
},
"explanation": "Groups employees by department and computes the headcount, the median salary and the mean salary in each, ranked by the median.",
"assumptions": [
"Salary is annual and in one currency for everyone."
],
"followups": [
"How does median salary rise from level L1 to L5 in each department?",
"Which offices pay above the company median?",
"What is the salary spread (standard deviation) per department?"
],
"cannot": ""
}
Result: 6 rows, columns department, employees, median_salary, avg_salary; first rows [["Engineering", 75, 129500, 133800], ["Finance", 13, 106500, 114346.15], ["Marketing", 34, 93750, 94411.76]]
Company roster - Salary vs years since hire (ask)
Input:
{
"task": "ask",
"question": "Plot salary against years since hire for every employee, colored by department.",
"schema": "TABLE employees -- 240 rows\n employee_id TEXT -- 240 distinct values\n department TEXT -- 6 values: 'Engineering', 'Sales', 'Support', 'Marketing', 'Operations', 'Finance'\n level TEXT -- 5 values: 'L2', 'L1', 'L3', 'L4', 'L5'\n hire_date DATE -- from 2011-05-02 to 2025-08-24\n salary NUMBER -- range 47000 to 193000, mean 101331.25\n office TEXT -- 4 values: 'Lisbon', 'Toronto', 'Remote', 'Singapore'\n performance_rating NUMBER -- range 2 to 5, mean 3.133\n manager BOOLEAN -- true/false",
"samples": "-- employees: first 5 of 240 rows\nemployee_id,department,level,hire_date,salary,office,performance_rating,manager\nE1000,Marketing,L2,2020-06-21,86000,Toronto,3,false\nE1001,Sales,L3,2019-06-04,120500,Remote,3,false\nE1002,Finance,L1,2024-01-28,76500,Lisbon,2,false\nE1003,Engineering,L1,2024-05-20,99000,Toronto,4,false\nE1004,Engineering,L4,2024-06-25,155500,Lisbon,4,true"
}
Reply:
{
"task": "ask",
"title": "Salary against years since hire",
"sql": "SELECT employee_id,\n department,\n ROUND(DATE_DIFF('day', hire_date, CURRENT_DATE) / 365.25, 1) AS years_since_hire,\n salary\nFROM employees\nWHERE hire_date IS NOT NULL AND salary IS NOT NULL\nORDER BY years_since_hire",
"chart": {
"type": "scatter",
"x": "years_since_hire",
"y": [
"salary"
],
"series": "department",
"title": "Salary vs years since hire"
},
"explanation": "Computes each employee's years since hire from hire_date to today and lists it next to their salary and department, one row per employee.",
"assumptions": [
"Years since hire is measured to today's date, in years of 365.25 days.",
"Employees missing a hire date or salary are left out."
],
"followups": [
"What is the average salary for each year of tenure?",
"Do managers earn more than non-managers at the same level?",
"Which department has the newest hires on average?"
],
"cannot": ""
}
Result: 240 rows, columns employee_id, department, years_since_hire, salary; first rows [["E1016", "Marketing", 1.2, 64500], ["E1094", "Operations", 1.2, 70000], ["E1199", "Marketing", 1.2, 72500]]
Company roster - A question the data cannot answer (ask)
Input:
{
"task": "ask",
"question": "Which employees are most likely to quit next year?",
"schema": "TABLE employees -- 240 rows\n employee_id TEXT -- 240 distinct values\n department TEXT -- 6 values: 'Engineering', 'Sales', 'Support', 'Marketing', 'Operations', 'Finance'\n level TEXT -- 5 values: 'L2', 'L1', 'L3', 'L4', 'L5'\n hire_date DATE -- from 2011-05-02 to 2025-08-24\n salary NUMBER -- range 47000 to 193000, mean 101331.25\n office TEXT -- 4 values: 'Lisbon', 'Toronto', 'Remote', 'Singapore'\n performance_rating NUMBER -- range 2 to 5, mean 3.133\n manager BOOLEAN -- true/false",
"samples": "-- employees: first 5 of 240 rows\nemployee_id,department,level,hire_date,salary,office,performance_rating,manager\nE1000,Marketing,L2,2020-06-21,86000,Toronto,3,false\nE1001,Sales,L3,2019-06-04,120500,Remote,3,false\nE1002,Finance,L1,2024-01-28,76500,Lisbon,2,false\nE1003,Engineering,L1,2024-05-20,99000,Toronto,4,false\nE1004,Engineering,L4,2024-06-25,155500,Lisbon,4,true"
}
Reply:
{
"task": "ask",
"title": "Likelihood of leaving next year",
"sql": "",
"chart": {
"type": "table",
"x": null,
"y": [],
"series": null,
"title": ""
},
"explanation": "The data has no record of who has left, resignations or engagement, so there is nothing to learn quitting from.",
"assumptions": [],
"followups": [
"How many employees were hired in each year?",
"Which departments have the lowest average performance rating?",
"How many employees in each level have been here under two years?"
],
"cannot": "Predicting who will quit needs past departures or signals such as engagement, and this table has neither - only department, level, hire date, salary, office, rating and the manager flag. A close question it can answer: tenure and rating by department."
}
Result: no SQL - the reply explains why the data cannot answer.
Website traffic - 7-day average of signups (ask)
Input:
{
"task": "ask",
"question": "Show the 7-day moving average of daily signups across all channels.",
"schema": "TABLE web_traffic -- 1365 rows\n \"date\" DATE -- from 2025-01-01 to 2025-09-30\n channel TEXT -- 5 values: 'Email', 'Organic', 'Paid', 'Referral', 'Social'\n sessions NUMBER -- range 101 to 1432, mean 499.752\n signups NUMBER -- range 3 to 52, mean 13.108\n ad_spend NUMBER -- range 0 to 1564.2, mean 172.174\n revenue NUMBER -- range 53.79 to 1507.84, mean 332.29",
"samples": "-- web_traffic: first 5 of 1365 rows\ndate,channel,sessions,signups,ad_spend,revenue\n2025-01-01,Organic,950,16,0,313.13\n2025-01-01,Paid,461,17,495.15,347.19\n2025-01-01,Social,408,5,150.61,134.65\n2025-01-01,Email,232,10,0,344.75\n2025-01-01,Referral,163,6,0,146.16"
}
Reply:
{
"task": "ask",
"title": "7-day moving average of daily signups",
"sql": "WITH daily AS (\n SELECT \"date\", SUM(signups) AS signups\n FROM web_traffic\n GROUP BY \"date\"\n)\nSELECT \"date\",\n signups,\n ROUND(AVG(signups) OVER (ORDER BY \"date\" ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2) AS signups_7d_avg\nFROM daily\nORDER BY \"date\"",
"chart": {
"type": "line",
"x": "date",
"y": [
"signups",
"signups_7d_avg"
],
"series": null,
"title": "Daily signups and 7-day average"
},
"explanation": "Adds up signups across channels for each day, then averages each day with the six days before it.",
"assumptions": [
"The first six days average over fewer than seven days.",
"Days with no rows are not filled in as zero."
],
"followups": [
"Which channel's signups grew fastest over the period?",
"What is the signup rate (signups per session) by channel each month?",
"Which weekday brings the most signups?"
],
"cannot": ""
}
Result: 273 rows, columns date, signups, signups_7d_avg; first rows [["2025-01-01", 54, 54], ["2025-01-02", 56, 55], ["2025-01-03", 56, 55.33]]
Website traffic - Share of sessions by channel (ask)
Input:
{
"task": "ask",
"question": "What share of sessions comes from each channel?",
"schema": "TABLE web_traffic -- 1365 rows\n \"date\" DATE -- from 2025-01-01 to 2025-09-30\n channel TEXT -- 5 values: 'Email', 'Organic', 'Paid', 'Referral', 'Social'\n sessions NUMBER -- range 101 to 1432, mean 499.752\n signups NUMBER -- range 3 to 52, mean 13.108\n ad_spend NUMBER -- range 0 to 1564.2, mean 172.174\n revenue NUMBER -- range 53.79 to 1507.84, mean 332.29",
"samples": "-- web_traffic: first 5 of 1365 rows\ndate,channel,sessions,signups,ad_spend,revenue\n2025-01-01,Organic,950,16,0,313.13\n2025-01-01,Paid,461,17,495.15,347.19\n2025-01-01,Social,408,5,150.61,134.65\n2025-01-01,Email,232,10,0,344.75\n2025-01-01,Referral,163,6,0,146.16"
}
Reply:
{
"task": "ask",
"title": "Share of sessions by channel",
"sql": "SELECT channel,\n SUM(sessions) AS sessions,\n ROUND(100.0 * SUM(sessions) / (SELECT SUM(sessions) FROM web_traffic), 2) AS share_pct\nFROM web_traffic\nGROUP BY channel\nORDER BY sessions DESC",
"chart": {
"type": "pie",
"x": "channel",
"y": [
"sessions"
],
"series": null,
"title": "Sessions by channel"
},
"explanation": "Totals sessions for each channel over the whole period and divides by all sessions to give each channel's percentage.",
"assumptions": [
"The share covers every date in the table."
],
"followups": [
"How did each channel's share of sessions change month by month?",
"Which channel converts sessions to signups best?",
"What is the revenue per session for each channel?"
],
"cannot": ""
}
Result: 5 rows, columns channel, sessions, share_pct; first rows [["Organic", 276114, 40.48], ["Paid", 175726, 25.76], ["Social", 116619, 17.1]]
Website traffic - Fix a query that fails (fix)
Input:
{
"task": "fix",
"question": "Cost per signup by channel for paid traffic",
"schema": "TABLE web_traffic -- 1365 rows\n \"date\" DATE -- from 2025-01-01 to 2025-09-30\n channel TEXT -- 5 values: 'Email', 'Organic', 'Paid', 'Referral', 'Social'\n sessions NUMBER -- range 101 to 1432, mean 499.752\n signups NUMBER -- range 3 to 52, mean 13.108\n ad_spend NUMBER -- range 0 to 1564.2, mean 172.174\n revenue NUMBER -- range 53.79 to 1507.84, mean 332.29",
"samples": "-- web_traffic: first 5 of 1365 rows\ndate,channel,sessions,signups,ad_spend,revenue\n2025-01-01,Organic,950,16,0,313.13\n2025-01-01,Paid,461,17,495.15,347.19\n2025-01-01,Social,408,5,150.61,134.65\n2025-01-01,Email,232,10,0,344.75\n2025-01-01,Referral,163,6,0,146.16",
"context": "Previous question: Cost per signup by channel for paid traffic\nPrevious SQL:\nSELECT channel, SUM(spend) / SUM(signups) AS cost_per_signup\nFROM web_traffic\nWHERE spend > 0\nGROUP BY channel\nORDER BY cost_per_signup",
"error": "No column named \"spend\". Did you mean \"ad_spend\"? Columns here: date, channel, sessions, signups, ad_spend, revenue. Names with spaces or capitals need double quotes."
}
Reply:
{
"task": "fix",
"title": "Cost per signup by channel for paid traffic",
"sql": "SELECT channel,\n ROUND(SUM(ad_spend) / SUM(signups), 2) AS cost_per_signup\nFROM web_traffic\nWHERE ad_spend > 0\nGROUP BY channel\nORDER BY cost_per_signup",
"chart": {
"type": "bar",
"x": "channel",
"y": [
"cost_per_signup"
],
"series": null,
"title": "Cost per signup, paid channels"
},
"explanation": "Divides each channel's total ad spend by its total signups, counting only days with spend, cheapest first.",
"assumptions": [
"Paid traffic means rows where ad_spend is above zero."
],
"followups": [
"How did cost per signup for Paid change month by month?",
"What is the revenue per unit of ad spend by channel?"
],
"cannot": "",
"fixed": "The table has no column named spend - the spend column is ad_spend, so both references now use it, and the ratio is rounded to cents."
}
Result: 2 rows, columns channel, cost_per_signup; first rows [["Social", 29.36], ["Paid", 32.99]]
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://ask-your-sheet.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://ask-your-sheet.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"]
// ask-your-sheet.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://ask-your-sheet.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://ask-your-sheet.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
}
// AskYourSheet.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 AskYourSheet {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://ask-your-sheet.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://ask-your-sheet.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://ask-your-sheet.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://ask-your-sheet.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":"ask-your-sheet"}' | 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": "ask-your-sheet"})["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: "ask-your-sheet" })).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": "ask-your-sheet"}, 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", "ask-your-sheet"), 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: "ask-your-sheet" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "ask-your-sheet"])["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 = "ask-your-sheet" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "ask", "question": "...", "schema": "TABLE ...", "samples": "..."}
# - 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": "ask", "question": "...", "schema": "TABLE ...", "samples": "..."}
# - 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: "ask" or "fix", question, schema, samples, context, error} (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 ask-your-sheet:<task>:<inputHash>:a<attempt> - the lane is part of the key - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. Both lanes reply with title, sql, chart, explanation, assumptions, followups and cannot (plus fixed on a fix). The code below prints the SQL to run, the chart and the explanation.
KEY="ask-your-sheet:ask:$(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 '.sql, .explanation, (.assumptions[]? | "- " + .), .cannot' reply.json # run the SQL on your own data
import hashlib, time
key = "ask-your-sheet:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
print(reply["sql"] or reply.get("cannot", ""))
print(reply.get("chart"), reply.get("explanation"))
for a in reply.get("assumptions") or []:
print("-", a)
import { createHash } from "node:crypto";
const key = `ask-your-sheet:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated);
console.log(reply.sql || reply.cannot);
console.log(reply.chart, reply.explanation);
for (const a of reply.assumptions || []) console.log("-", a);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "ask-your-sheet:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
fmt.Println(reply["sql"], reply["cannot"])
fmt.Println(reply["chart"], reply["explanation"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "ask-your-sheet:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits"));
System.out.println(reply.path("sql").asText(reply.path("cannot").asText()));
System.out.println(reply.path("chart") + " " + reply.path("explanation").asText());
require "digest"
key = "ask-your-sheet:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"]].inspect
puts reply["sql"].to_s.empty? ? reply["cannot"] : reply["sql"]
puts reply["chart"].inspect, reply["explanation"]
$key = "ask-your-sheet:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], "\n";
echo $reply["sql"] ?: ($reply["cannot"] ?? ""), "\n";
echo json_encode($reply["chart"] ?? null), " ", $reply["explanation"] ?? "", "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"ask-your-sheet:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine(j["charged_credits"]);
Console.WriteLine(reply!["sql"]?.GetValue<string>() is { Length: > 0 } s ? s : reply["cannot"]);
Console.WriteLine($"{reply["chart"]} {reply["explanation"]}");
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: ask-your-sheet:ask:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "ask", "question": "...", "schema": "TABLE ...", "samples": "..."}
# - 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": "ask-your-sheet:ask:" + 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": `ask-your-sheet:ask:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "ask-your-sheet:ask:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "ask-your-sheet:ask:" + 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" => "ask-your-sheet:ask:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: ask-your-sheet:ask:" . 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", $"ask-your-sheet:ask:{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);