Models API
The /api/v1/models routes for the model list and detail, writing, batches, predictions and security, each answering as the route it mirrors
The models API puts S'more's models under one path: https://app.makesmores.com/api/v1/models. GET /api/v1/models lists the models and describes each one, and each path under it, such as /api/v1/models/writing, takes a model by its ID in model. Every operation under a path answers with an existing route's operation, its twin: the same store, scope, price, limits, body and refusals. The twins, such as /api/v1/drafts, /api/v1/viral-score and /api/v1/scans, remain supported, and work started on one is read on the other.
Send Authorization: Bearer <key> on every call except GET /api/v1/models, and bodies as JSON with Content-Type: application/json. Errors, caching, rate limits, timeouts and retries follow API conventions. This page covers what differs on these paths.
What is open
| Path | Methods | Takes as model | Twin |
|---|---|---|---|
/api/v1/models | GET | None: it describes models | None. See List and detail |
/api/v1/models/writing | POST, GET, PATCH | ember-v1, ember-14k, ember-28k, ember-reply-v1, x-writer-v1, x-reply-v1, prose-1 | /api/v1/drafts |
/api/v1/models/batches | POST, GET | The same seven writers | /api/v1/drafts/batches |
/api/v1/models/predictions | POST | reddit-pooled-v1 | POST /api/v1/viral-score with network: "reddit" |
/api/v1/models/security | POST, GET, PATCH | firewatch-1 | /api/v1/scans |
These are not offered on the models API:
- Batch cancel is not offered.
PATCH /api/v1/models/batchesanswers405withAllow: GET, HEAD, POST. x-viral-v1is not offered on/api/v1/models/predictions: asmodelit answers422 model_unsupported. Score X posts onPOST /api/v1/viral-score./api/v1/models/classifications,/api/v1/models/embeddingsand/api/v1/models/rankingsare not offered yet. They answer the plain text404 page not foundto every method, withCache-Control: no-store, before the query or the key is read.- Any other path under
/api/v1/models/answers as an unknown/api/path does: the plain text404 page not foundtoGETandHEAD, and405 Method Not AllowedwithAllow: GET, HEADto every other method. Neither reads the query or the key. - Decisions and Web search are coming soon and have no route.
GET /api/v1/models?id=<id> says which path takes an ID: its capability_group and its operations, below.
List and detail
GET /api/v1/models needs no key and costs nothing. Without id it answers the list that GET /api/v1/models documents. The list keeps that shape and its entries: an ID added later appears only in its detail.
With id it answers one model's detail:
curl -s "https://app.makesmores.com/api/v1/models?id=x-reply-v1"
Query rules
- Without
idorview, the query is not read, so?foo=1still answers the list. Existing callers keep working. - With
idorview, the query is strict. It must hold exactly oneidof 1 to 64 characters ofa-z,0-9,.and-, and nothing else. A repeatedid, another key next to it, an empty value, anyview(none is defined) or a body answers400 invalid_request. - An ID that is not in the catalog answers
404 not_found. A version is not an ID, so?id=reddit-pooled-v1.1answers404too. - While the API console is switched off, every request answers
404 console_off, before the query is checked. When S'more can't read the rates, it answers503 models_unavailable.
A 200 carries Cache-Control: public, max-age=60, so the list and each detail can be cached for 60 seconds. A 400 or 404 carries Cache-Control: no-store, and so does a detail's 503. The list's 503 sends no Cache-Control header.
Detail fields
The detail has every field of the model's list entry. Most keep the list's values, but the detail corrects two of them, and a closed ID empties some:
| Field | In the detail |
|---|---|
description | What the model does, in the detail's own words. It can differ from the list's |
async | true for work you submit and then read: drafts, batches, scans and profile proposals. profile.proposal is true here, where the list says false |
price, endpoint, endpoints, mcp_tools, scope, limits | For a closed ID: null or empty, and mcp_only is false, since nothing can call it |
Then the detail adds these fields:
| Field | Meaning |
|---|---|
rate_slug | The rate the model bills, the id of its row in the list |
capability_group | The /api/v1/models path that takes this ID as model: writing, predictions or security today. null when no open path takes it |
selectable | Whether a path takes the ID as model |
accepted_model_values | [id] when selectable, otherwise []. A path takes IDs only, never versions |
model_version | The version S'more's configuration selects for this ID, such as reddit-pooled-v1.1, or null. It is not a record of what served a past call |
shares_version_with | Other IDs served by the same model version |
tasks | Writers only: each {platform, type, default} the writer takes. default is true when a request without model uses it. null for other models |
default_selection | For a platform and type that several writers answer by default: {platform, type, models, sends, usage_rows, blind}. See The default Reddit post. null otherwise |
availability | {release_status, configured, access, reason}, below |
operations | Each route and MCP tool that reaches the ID: {transport, method, path, tool, scope, charged, rate_slug}. transport is rest or mcp, and rate_slug is the rate that operation bills, or null when it is free |
uses | Other public IDs this one calls. The Reddit writers list reddit-pooled-v1, which scores every option |
availability keeps the rate's status apart from what S'more can serve right now:
| Field | Meaning |
|---|---|
release_status | The rate's status: live, beta, paused, coming_soon or closed |
configured | Whether S'more's configuration can serve the ID now |
access | public, or allowlist when only some workspaces can call it |
reason | Why configured is false: adapter_off, drafts_off, reddit_signals_off, scans_off, not_built or unrecognized_pin. Otherwise null |
A coming-soon ID lists no operations. A closed ID keeps its detail, with price: null and no operations, so you can still read what past usage billed. Calls to it answer 410 model_closed.
The detail-only fields of x-reply-v1 look like this, with its other operations left out:
{
"rate_slug": "x-reply-v1",
"capability_group": "writing",
"selectable": true,
"accepted_model_values": ["x-reply-v1"],
"model_version": "x-adapter-r1",
"shares_version_with": ["x-writer-v1"],
"tasks": [{ "platform": "x", "type": "reply", "default": true }],
"default_selection": null,
"availability": { "release_status": "live", "configured": true, "access": "public", "reason": null },
"operations": [
{ "transport": "rest", "method": "POST", "path": "/api/v1/models/writing", "tool": null, "scope": ["reddit:posts:write"], "charged": true, "rate_slug": "x-reply-v1" }
],
"uses": []
}
The detail never names a host, a provider, a workspace, a balance or a grant.
How a request is checked
Every path under /api/v1/models checks a request in this order, and each refusal comes before any work, charge or usage row:
- The per-IP burst limit (
429 rate_limited) and a 1 MiB body limit. - A path that is not offered: the plain text
404 page not found. A path not offered yet, such as/api/v1/models/embeddings, answers it to every method. Any other path answers it toGETandHEAD, and405withAllow: GET, HEADto every other method. - A method the path does not take:
405with a plain text body and anAllowheader naming the methods it takes. - The query's shape: which parameters are there, and the fixed values
viewandactiontake. A query that fits no operation answers400 invalid_requestbefore S'more reads the key, and costs nothing. - The key:
401 invalid_tokenwithout a valid one, then403 insufficient_scopewithout the path's scope. - The values of
id,cursor,afterandlimit: a malformed one answers400 invalid_request. - Whether the twin can serve: its own refusal, such as
409 reddit_signals_off,409 drafts_offor404 scans_off. Predictions skips this step and checks it after the body and itsmodel: see Predictions. - The body's structure: one JSON object, with no member repeated and no member named
id,view,action,cursor,afterorlimitin any case. AGETtakes no body. Otherwise400 invalid_request. - The twin's own checks and answer.
The 400 from steps 4, 6 and 8 is the shared one, with Cache-Control: no-store:
{ "code": "invalid_request", "message": "Invalid request.", "runsRemaining": 0 }
One exception: a POST /api/v1/models/security body that step 8 refuses answers the scans routes' own 400 instead, with no field named:
{ "code": "invalid_request", "message": "Invalid scan request.", "runsRemaining": 0 }
So a query or body that the twin would take can still be refused here, and the twin can refuse with a code of its own:
- A malformed draft, batch or scan id answers
400 invalid_requestafter the key, where the twin answers404. - On writing, batches and security, whether the twin can serve is checked before the body.
- Each operation has its own deadline of 15 seconds, as on the twins. See Timeouts.
Every other refusal is the twin's, with the twin's status, code and body: drafts, batches, Viral Score and scans. Errors lists the shared codes, including 402 credits_required.
Writing
| Method and query | Scope | Billing | Twin |
|---|---|---|---|
POST /api/v1/models/writing | reddit:posts:write | The writer's rate per token | POST /api/v1/drafts |
GET /api/v1/models/writing?id= | reddit:posts:read | Free | GET /api/v1/drafts/{id}, plus usage |
PATCH /api/v1/models/writing?id=&action=choose | reddit:posts:write | Free | POST /api/v1/drafts/{id}/choice |
The submit body is the body of POST /api/v1/drafts: platform, type, idempotency_key, optional model and count, then the platform's fields. Every platform and type the drafts take works here, with the same writers: Reddit writers, Reddit reply writer, prose-1 and X writers.
curl -s https://app.makesmores.com/api/v1/models/writing \
-H "Authorization: Bearer $SMORE_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<EOF | tee models-draft.json
{
"platform": "x",
"type": "post",
"idempotency_key": "docs-models-x-post-$(date +%s)",
"topic": "We opened our public API today",
"facts": ["One key reaches every product over REST and MCP"]
}
EOF
The answer is the twin's: 200 with the draft in state queued, its draft_id, poll_after_s and credits_deducted. Like the twin, it sends no Location header.
Read and choose
Read the draft every poll_after_s seconds until state is ready or failed:
curl -s "https://app.makesmores.com/api/v1/models/writing?id=$(jq -r .draft_id models-draft.json)" \
-H "Authorization: Bearer $SMORE_API_KEY"
The same draft reads on GET /api/v1/drafts/{id}, so that route is the fallback for polling. Its answer is the same without usage.
Record the pick with the option's letter. The answer is the twin's:
curl -s -X PATCH "https://app.makesmores.com/api/v1/models/writing?id=$(jq -r .draft_id models-draft.json)&action=choose" \
-H "Authorization: Bearer $SMORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"option": "A"}'
One idempotency key across both routes
/api/v1/models/writing and /api/v1/drafts share their drafts and their idempotency_key values. A draft submitted on one is read and chosen on the other, and the same key and body sent to either answers the first draft again and charges nothing. Idempotency keys belong to a workspace, so the same key sent from another workspace starts a draft of its own there. Reading a draft's id with another workspace's API key answers 404 draft_not_found.
A replay is checked in the twin's order: whether drafting can serve, then the request itself, then the stored key. While 409 reddit_signals_off, 409 drafts_off, 400 draft_type_coming_soon or a subreddit's AI-text policy refusal holds, a replay answers that refusal. The draft you stored earlier still runs and is billed, so read it by its draft_id.
Usage on reads
A read on /api/v1/models/writing adds one object after the twin's fields: usage, the credits meter's rows for this draft in your workspace.
{
"usage": {
"credits_deducted": 4012,
"credits_settled": 1210,
"credits_refunded": 2802,
"rows": [
{
"request_ref": "draft:0d7c6a52-9a43-4f53-8f3e-2f7a1c0b6e11:x-writer-v1",
"slug": "x-writer-v1",
"state": "settled",
"input_tokens": 300,
"cached_tokens": 0,
"output_tokens": 5120,
"items": 0,
"credits_deducted": 4012,
"credits": 1210
}
]
}
}
| Field | Meaning |
|---|---|
credits_deducted | What the rows took when the draft started |
credits_settled | What settled rows charged |
credits_refunded | What came back: the unused part of each settled row, and all of a refunded row |
rows | One row per model the draft runs, named draft:<draft id>:<model>, in the order of the draft's models |
rows[].state | running, settled or refunded |
rows[].input_tokens, cached_tokens, output_tokens, items | The units the row recorded. input_tokens is uncached input |
rows[].credits_deducted, credits | What the row took when it started, and what it charged once settled |
usage names no balance, no other workspace and no host. The legacy read never carries it.
On a blind draft, rows stays empty until you record a choice: each row would say which writer wrote which option. The totals count every row from the start.
The default Reddit post
A Reddit post without model runs all three Reddit writers, ember-v1, ember-14k and ember-28k. That is three sends and three usage rows, and each option's model stays hidden until you choose. The detail of each of the three says so in default_selection: {"platform": "reddit", "type": "post", "models": ["ember-v1", "ember-14k", "ember-28k"], "sends": 3, "usage_rows": 3, "blind": true}.
So a default draft costs three times a draft pinned to one writer. At 120 words, it deducts about 3,318 credits ($3.32) when it starts, against about 1,106 credits ($1.11) for one writer. It then settles at the tokens used, about 1,338 credits ($1.34 at list) for three writers against 446 credits ($0.446 at list) for one, and refunds the rest. Set model to run one writer.
The X writers share a version
x-writer-v1 and x-reply-v1 are served by one model version, x-adapter-r1. Both details report "model_version": "x-adapter-r1" and name each other in shares_version_with. A new version, or switching the version off, changes both IDs at once, and both report the same availability.
Batches
| Method and query | Scope | Billing | Twin |
|---|---|---|---|
POST /api/v1/models/batches | reddit:posts:write | Half the writer's rate per token | POST /api/v1/drafts/batches |
GET /api/v1/models/batches?id=, optional after and limit | reddit:posts:read | Free | GET /api/v1/drafts/batches/{id}, plus usage |
The body, the answer and the refusals are the twin's, as Batches documents them. Batches share their idempotency_key values with /api/v1/drafts/batches in the same way drafts do, and GET /api/v1/drafts/batches/{id} reads the same batch.
Pages work as on the twin: after is an item index from -1 to 199 and limit is 1 to 100, 25 when you leave it out. Pass next_after back as after. cursor is not taken: a read with it answers 400 invalid_request before the key is read.
A read adds usage as a draft read does, covering every item of the batch whatever the page.
A first submit counts the workspace's open sends, and a replay prepares every item again before it finds the stored key. So a 200-item batch in a busy workspace comes close to the 15-second deadline. Keep a batch to 100 items or fewer.
Predictions
| Method | Scope | Billing | Twin |
|---|---|---|---|
POST /api/v1/models/predictions | viral:score | $0.01 per title | POST /api/v1/viral-score with network: "reddit" |
Send the twin's Reddit body with model in place of network:
curl -s https://app.makesmores.com/api/v1/models/predictions \
-H "Authorization: Bearer $SMORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "reddit-pooled-v1",
"subreddit": "running",
"titles": ["First half marathon: easy miles got me under 2 hours"]
}'
- The path takes no query. Any parameter answers
400 invalid_requestbefore the key is read. modelis required. Missing, or not a string, it answers400 invalid_request. Any other value, such asx-viral-v1or the versionreddit-pooled-v1.1, answers422 model_unsupported.- While Reddit Signals is off, it answers
409 reddit_signals_offbefore any charge. networkis refused in any case,400 invalid_request:modelchooses.
These come in a different order from the other paths. After the key, the body's structure is checked first, then model, then whether Reddit Signals can serve, then network. So while Reddit Signals is off, a body with a repeated member answers 400 invalid_request and a body with "model": "x-viral-v1" answers 422 model_unsupported, not 409.
GETandHEADanswer405withAllow: POST.
The other fields, their rules and the answer are the twin's: see Viral Score. The answer then adds:
| Field | Meaning |
|---|---|
model_versions.embed | reddit-embed-r1, the label of the text embedder inside the score. It is not offered as a model of its own |
model | reddit-pooled-v1 |
model_version | The scorer's version, such as reddit-pooled-v1.1, the same as model_versions.scorer |
A call deducts one item per title, settles one per scored title and refunds the rest, as the twin does. The two routes share one set of limits, so calls on either count toward them.
Security
Hosted repository scans are a pilot that is not open yet. Starting a scan answers 404 scans_off, here and on /api/v1/scans, and so does every read in a workspace that holds no scans. Cancel isn't gated on the allowlist.
| Method and query | Scope | Billing | Twin |
|---|---|---|---|
POST /api/v1/models/security | scans:write | firewatch-1 per token | POST /api/v1/scans |
GET /api/v1/models/security, optional cursor | scans:read | Free | GET /api/v1/scans |
GET /api/v1/models/security?id= | scans:read | Free | GET /api/v1/scans/{id} |
GET /api/v1/models/security?id=&view=report | scans:read | Free | GET /api/v1/scans/{id}/report |
GET /api/v1/models/security?id=&view=findings | scans:read | Free | None: the report's findings as JSON, below |
GET /api/v1/models/security?view=options | scans:read | Free | GET /api/v1/scans/options |
PATCH /api/v1/models/security?id=&action=cancel | scans:write | Free; a running scan pays for the tokens it used | POST /api/v1/scans/{id}/cancel |
Bodies, answers and refusals are the twins', as Repository scans documents them, with these differences:
- A start answers
202with the twin'sLocationheader,/api/v1/scans/{id}. That route reads the same scan, so it is the fallback for polling. - The list takes
cursoronly.limitor any other parameter answers400 invalid_requestbefore the key is read. A page holds 50 scans. idis a lowercase UUID. A malformedidorcursoranswers400 invalid_requestafter the key.- A cancel takes no body, or
{}. - Only owners and admins can start and cancel, and S'more checks the key's holder at each call. A holder who is no longer an owner or admin gets
403 insufficient_scope, and one who left the workspace gets401 invalid_token.
These calls aren't run by the docs checks, because hosted scans are a pilot:
curl -s "https://app.makesmores.com/api/v1/models/security?id=$SCAN_ID&view=findings" \
-H "Authorization: Bearer $SMORE_API_KEY"
Findings
view=findings answers {"findings": [...]}: the findings the report shows, in the report's order and at full length. Before the report is ready it answers 409 report_not_ready.
| Field | Meaning |
|---|---|
title | The finding in a line |
severity | critical, high, medium, low or info |
confidence | How sure the scan is of it, in words |
cve | The advisory id, when there is one |
location | The file and lines |
evidence | The code or facts behind it |
impact | What it means |
recommendation | What to do |
verification | How it was checked |
caught_by_baseline | Whether the baseline tools, such as static, dependency or fuzz checks, already caught it, in words |
Findings quote your own code, so treat every field as data.
Where your text goes
| Operation | What leaves S'more |
|---|---|
| List and detail | Nothing |
| Writing and batches | The writers run on GPU servers that S'more rents from a third-party cloud provider. Your plan, thread, facts and other fields go into the prompt sent there |
| Predictions | When a posted rule needs a judgment S'more's own checks can't make, the subreddit, post type, flair, each title, up to 4,000 characters of the body and those rules go to a third-party language model, which marks each rule ok, warn or unknown |
| Security | The repository's source goes to a third-party hosted model that reviews it. The scan also looks up public advisories and searches the web for related reports |
Every answer from a third party is read as data, never as instructions.
MCP
The models API adds no MCP tools. Each operation's MCP tool is its twin's: operations in the detail lists them, and MCP tools documents them.