Repository navigation
v0.15.0
Upgrading from 0.14
This release has breaking changes. Each one has a full entry below.
- DBOS 3 and DBOS Transact TS v5 are required (
dbos>=3.3,<4,
@dbos-inc/dbos-sdk ^5). DBOS 3 cannot read workflows written by 2.x:
let in-flight 2.x workflows finish, then re-emit your DBOS apps. - Implementations receive their node's constants. On every runtime,
a node'sconstants:arrive as arguments (Python keyword arguments,
TS input fields).rote emitnow refuses a Python implementation that
can't accept them and names the file to fix. Make constants that every
caller passes required parameters with no default. - Agent loops return their declared output keys. A loop now submits
its result through asubmit_resulttool that matches the node's
declaredoutput:, and downstream nodes receive those keys. Provider
and iteration counts moved to the usage log. anthropic>=1.15andfastmcp>=4.1.1,<5are the new floors,
andpytestis now a runtime dependency, so compile-time verification
works on a plainpip/uvinstall.
Added
mcp.argstakes fixed values:{literal: "BDR do not contact"}, or a
number or boolean, next to the existing references (a bare string,
which always names a payload key or a constant). A real BDR compile
wrote literal text there and the IR rejected it. The error now names the
{literal: ...}form. Types are strict and validated in the IR, and
every MCP runtime (dbos, dbos-ts, cloudflare, inngest) renders a literal
as an escaped value, never spliced code. The compile rubric tells the
agent to use literals for fixed tool arguments.rote example [<name>]: the free first run without a clone. pip and
uv installs never gotexamples/, so the expense-audit run in the
README started withgit clone. The wheel now carries the examples
that run with no credentials (onlyexpense-auditfor now), packaged
fromexamples/by pyproject's force-include rather than copied into
src/.rote examplelists them.rote example expense-auditcopies
the skill, both IRs, the workingextracted/modules and the sample
inputs into./expense-audit(refusing an existing directory unless
--force), then prints the emit, run and eval commands for that copy.
uvx --from rote-cli rote example expense-auditworks with nothing
installed.- The scorecard prices Cloudflare Workflows infrastructure. Steps
and storage started billing on 2026-08-10. For acloudflareor
rote-cloudpipeline,rote evalandrote compile's scorecard now
add a per-run table of steps, requests, CPU and storage at the paid
plan's marginal rates, plus how many runs a month fit in the included
steps. Prices come live from the table in
cloudflare/cloudflare-docs@production:src/content/partials/workflows/workflows-pricing.mdx
with the model catalog's 24h cache, and the scorecard cites the file
and fetch time. A missing row or a cell outside the known grammar is a
loudPricingError, never a guessed number. Steps per run come from
the IR, with the same fan widths as the token estimate. Gates count as
a step (a stated, conservative assumption). CPU per step, state bytes
per step and retention (30 days, the Workers Paid account maximum)
are stated priors, each overridable with--prior.rote eval --runtimepicks the runtime; acompile --outdir with one emitted
runtime is detected. - A free first run.
examples/expense-audit/is a month-end expense
audit skill with a hand-compiled pipeline whose extracted modules are
working code, sorote emit+rote runfinish end to end with no
credentials and no tokens on python (pipeline-draft.yaml, no gate),
dbos, and temporal (pipeline.yaml, with acontroller_reviewHITL
gate). It covers a three-node parallel entry wave, afan_outaudit
per employee, mandatory checks, and policy constants lifted out of
prose. The README opens with it as "Try it free in 60 seconds", and
tests/test_expense_audit_example.pyruns it on python in the fast
suite and on dbos and temporal in the slow suite (now in CI). The
example also carries a measuredrote eval --runscorecard (agent
42.9 s and $0.22 per run vs pipeline 9.8 s and $0, identical output
only on the pipeline side) and a snapshot of a realrote compileof
the skill underruns/, with its differences from the hand-written IR
noted. rote mcp loginfollows the MCP 2026-07-28 registration order. A
pre-registered client from the registry wins, then rote's Client ID
Metadata Document (client_idhttps://roteskills.com/oauth/rote-cli.json,
source indocs/oauth/rote-cli.json) when the authorization server
advertisesclient_id_metadata_document_supported, then dynamic client
registration. A server that offers neither (GitHub's shape) now fails
with an error namingrote mcp add --client-id, instead of a POST to a
guessed<resource-origin>/register. The login binds the first free
port of a published set (33418 to 33421), because a CIMD server matches
redirect URIs exactly.ROTE_MCP_CLIENT_METADATA_URLoverrides the
document URL; empty turns CIMD off. The document is offered only after
a fetch shows it published and identical to the packaged copy; until
then the login uses DCR as before, with a one-line notice.rote mcp add --client-idrecords the issuer the client belongs to
(discovered from the server's protected-resource metadata, or
--issuer), so a pre-registered client is bound from its first login.- Token file contract v2 adds
issuer, the authorization server the
credentials belong to. Readers accept v1. A pre-registered client is
bound to the issuer it logged in with, and a later login against a
different authorization server stops before the browser opens. RoteMcpInputRequired. A server that asks for input in the middle
of a call (a 2026-07-28input_requiredresult, or-32021for a
capability the client did not declare) fails the step with the server,
tool, and requested inputs named, on all four MCP runtimes. It is not
an auth failure: it never parks, and it skips the retry budget./rote:implementplugin skill. Afterrote emitorrote compile,
the Claude Code plugin can now fill in the emittedextracted/*stubs
without spending compilation tokens. The skill finds the TODO list
throughrote emit --json'sunimplemented_stubs, reads each node's
IR contract and its source SKILL.md section, writes real code in
user-owned files only, handles.newsiblings from re-emit, and
verifies withrote run.tests/test_plugin_skills.pychecks every
plugin skill's frontmatter and links, runs each stub and non-stub
example in the skill through rote's own stub detector, and emits every
runtime to confirm that each file outsideunimplemented_stubs
(generated agent-loop modules included) is on the skill's do-not-edit
list.- The compiler writes real code for
pure_functionnodes, with tests.
A parser, validator, or renderer the skill spells out is implemented,
andtests/test_<module>.pychecks it against cases built from the
skill's own rules and examples. A pure_function stays a stub only when
the skill leaves a fact unstated, and the stub'sNotImplementedError
message says which. Vendor calls inexternal_callmodules stay stubs
unless anmcp:binding (or probe ground truth) makes them real. rote compilechecks the deliverable before calling it done, and the
agent repairs what fails. After the agent stops, rote validates the
IR, checks every module the IR names (it defines the function, accepts
the node's payload, and parses on Python 3.11), requires every
pure_function to be implemented and tested or an explained stub, and
runs the generated tests. Problems go back into the same conversation,
verbatim, for up to two repair rounds. Theclaudedriver now repairs
too, throughclaude -p --resume. A deliverable that still fails is
kept, androte compileexits 1 and skips the rote cloud deploy.
The summary prints how many impl nodes were implemented and every
stub with its reason (verificationin--json).- Generated tests run in an OS sandbox, or not at all. They are code
an LLM wrote from a possibly third-party skill, so rote runs them under
sandbox-exec(macOS) or bubblewrap (Linux): no network, no reads
outside the work dir and the Python install, no exec but the
interpreter, an env of onlyPATHand locale, and an empty throwaway
HOME. With no working sandbox they are skipped, and the compile
output and a## rote verificationsection in compile-report.md say
the deliverable is unverified.--trust-skillruns them without a
sandbox;--no-verify-testsnever runs them (both also on
rote analyze). The static checks always run. - Every outcome the compiled code decides cites the skill, or is a
declared assumption. The rubric has the agent quote the skill text
behind each value, category, and branch (# skill: "..."), and mark
an outcome the skill leaves open# rote: assumption:. Verification
rejects a quote the skill does not contain, and recorded assumptions
with an empty Open questions list in compile-report.md. rote.contractsreports syntax that parses on the running Python but
not on 3.11 (needs_newer_python).ast.parse(feature_version=...)
misses PEP 701 f-strings, and a real compile on 3.12 wrote a backslash
inside an f-string expression that 3.11 cannot import.
Changed
- CI and the release workflow's test job install from
uv.lock
(uv sync --frozen --extra devviaastral-sh/setup-uv) instead of
pip install -e ".[dev]", which resolved the newest versions on every
run and so never tested the lockfile developers use. Each job also
runsuv lock --check, failing when the lock is out of date with
pyproject. The newest-dependencies workflow still covers what a fresh
PyPI install gets. The Python e2e job also reserves the OAuth callback
ports (33418-33421) from Linux's ephemeral range, and the registration
tests check the first port is free before they start: one run had an
unrelated socket on 33418 for its whole duration, which surfaced as
nine "rejected the static client credentials" failures. - The
claudecompiler driver runs isolated from the invoking user's
Claude Code setup: the same context flags as the emitted judge lane
(--setting-sources "",--strict-mcp-config, auto memory off, now
one shared definition),--toolsset to its allowlist, and the work
dir as its cwd. A user's
prose-lint hook had cost a real compile 12 of its 48 turns in one-line
report edits, the same settings granted Bash, and run from a repo the
agent read that repo's earlier compiled output of the same skill. - The compiler rubric writes
compile-report.mdonce, in one Write at
the end, and never spends a turn on a progress marker alone. - The DBOS runtimes target DBOS 3 (Python) and DBOS Transact v5
(TypeScript). Thedbosextra now requiresdbos>=3.3,<4. Emitted
main.pyno longer constructs aQueue(DBOS 3 raises on import);
it declaresQUEUE_NAME, enqueues throughDBOS.enqueue_workflow,
and exposeslaunch(), which runsDBOS.launch()and then
DBOS.register_queue. Anything that runs an emitted app in-process
must calllaunch()instead of a bareDBOS.launch(), or runs
enqueued byrote servestayENQUEUED. The removed
run_admin_serverkey is gone from both runtimes' config, and the
emitted DBOS-TSpackage.jsonpins@dbos-inc/dbos-sdk^5.3.0.
Supporting 2.x alongside 3.x was rejected: a 2.x process or client
cannot read workflows 3.x wrote, so a rote CLI on 2.x would fail
against an app installed today. Apps emitted for 2.x need a re-emit. rote mcp login/releasenow finds a parked DBOS-TS workflow's
system database the way the v5 SDK does. v5 stopped reading
dbos-config.yamlat launch, so asystem_database_urlkey there no
longer counts, and the default database name follows the SDK's
toDbName()(lowercased, leading digit prefixed).- A new
Newest dependenciesworkflow installs every extra at the top
of its range with no lock or cache, daily and on PRs that touch
dependencies or DBOS code, and runs the fast suite plus the DBOS
Python and DBOS-TS e2e suites. An upstream major that breaks rote now
turns CI red instead of reaching fresh installs first. - fastmcp 4. The
mcpandserveextras now require
fastmcp>=4.1.1,<5(MCP Python SDK v2, httpx2), replacing the
>=3.4.2,<4pin. Verified against FastMCP, TypeScript SDK v1 and v2,
and five hosted servers on both protocol eras. Emitted DBOS READMEs
print the same range. Supersedes the blind<5bump in #92. - Emitted TS helpers speak MCP 2026-07-28.
_roteMcp.ts(DBOS-TS,
Inngest) and the Workers helper move from@modelcontextprotocol/sdk
^1.29 to@modelcontextprotocol/client^2.3.1 with
versionNegotiation: { mode: "auto" }. v1 cannot reach a modern-only
server at all (-32022), and v2's defaultlegacymode behaves the
same, so the mode is set explicitly. A 401 is now read from
SdkHttpError.status. Proven against legacy stateless, legacy
sessionful, dual-era and modern-only TS SDK servers, from both helper
languages, intests/test_mcp_era_matrix_e2e.py. rote mcp headers(and any refresh outsidelogin) never opens a
browser or registers a client: it refreshes or fails with the login
hint.- Breaking: an impl must accept its node's constants. The Python
runtimes (dbos, python, temporal) now pass apure_function/
external_callnode's IRconstantsto itsimplas keyword
arguments, merged in the step body so every caller gets them: the DAG,
each fan_out element, and an agent loop calling aloop_bodysub-node
as a tool. Before, no step received them, so nodes sharing oneimpl
with different constants (ops-report's three fetch nodes, each with its
ownfile_id) all got the same empty payload. Anextracted/
function that takes neither the constant nor**kwargswould raise
TypeError mid-run, so emission now refuses it up front, naming the node
and parameter, androte emitreports it as aconstant_not_accepted
contract finding. Most committedexamples/*/runs/hard-code their
constants and no longer emit on a Python runtime until recompiled. The
BDR and expense-audit example modules take theirs as keyword
parameters, and expense-audit's now apply them, so editing a constant
in its pipeline.yaml changes the rule. The refusal names the full path
of the file to fix, androte compileexits 1 with therote emit
retry command instead of a traceback. - A constant reaches an MCP tool only when
mcp.argsnames it, the same
on every runtime. A constant that reuses an input's name is an IR
validation error for impl-backed kinds. The stub for a sharedimpl
lists every calling node's constants. - Breaking (TS): the TS runtimes pass each node's constants to its
impl, as the Python runtimes do. Cloudflare, Inngest and DBOS-TS add
apure_function/external_callnode's IR constants to the impl's
input after its inputs, on DAG calls, on each fan_out element and on an
agent loop's loop_body tool calls (the model's tool schema never shows
them). Before, they only appeared in the stub's doc comment, so a TS
impl had to hard-code them and an edit in pipeline.yaml did nothing. An
MCP-backed step still sends a tool a constant only whenmcp.args
names it. A typed TS impl whose input type does not declare its node's
constants now failstscwith an excess-property error; add them to
the input type.
Fixed
- Assumptions no longer fail a compile. A real bdr-outreach compile
exited 1 over 15 declared# rote: assumption:markers and one
citation that was verbatim but for its quote marks. Now only a
citation the skill does not contain fails. Every assumption is listed
by node andfile:linein the compile output, compile-report.md,
--jsonand the progress.jsonl summary. An unmarked outcome or an
assumption missing from Open questions is still sent back for repair,
and is a warning if it survives.--strict-provenance(compile and
analyze) fails on any of it, for CI. Citations now match regardless of
quote style (',", curly quotes). - Python string literals in emitted code keep characters outside the BMP
intact.json.dumpsspelled one as a surrogate pair, which Python reads
as two lone surrogates, so a constant like an emoji reached its impl or
tool changed. - Untrusted IR text never lands invisible in emitted source. A bidi
override, zero-width space, BOM or other control or format character
from a pipeline.yaml is written as an escape in Python literals,
docstrings, TS comments and the Cloudflare README (the Trojan Source
shape, CVE-2021-42574). A lone surrogate, which used to crash emit with
UnicodeEncodeError partway through writing files, now emits code that
round-trips it. - A failed
claude -pcompile reports what it cost, and the output cap
no longer ends it. A real invoice-push compile died after 28 minutes
on "Claude's response exceeded the 32000 output token maximum" and
reported no cost anywhere. The failure's usage and cost are now in the
error and in progress.jsonl. The compiler's CLI child gets
CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000unless the caller set one, and a
run that still hits the cap is resumed once with an instruction to
write large files in pieces. - Generated tests run under a uv-managed Python. CI's setup-uv
interpreter lives in~/.local/share/uv/python, and every sandboxed
generated-test run died with "No module named 'encodings'": the child
was launched through the venv, whosepyvenv.cfgpoints at a uv
minor-version symlink nothing in the bwrap namespace binds. The child
is now launched by the resolved binary. The run's summary is also
taken from pytest's stdout, not from a termcap warning the interpreter
prints to stderr on exit. The sandbox tests now check the real
property (no bind exposes the home's contents, planted secrets in the
home and the venv root are unreadable) instead of "nothing under /home
is bound", which failed on any in-tree venv. - A compile no longer declares success on an IR
rote emitrefuses.
A real bdr-outreach compile ($4.46) exited 1 at emit because
email_template_looptook its ownloop_bodysub-node
personalize_emailas an input, a rule only the adapters checked.
Input reachability (no same-wave or later source, no loop-body
sub-node) and cycles are now IR validation errors, so the repair loop
sends them back. Verification also dry-emits for the compile's
--runtimeand sends any refusal back (emit_refused), which covers
adapter-only rules such as python refusing ahitl_gate. The rubric
says a loop never binds its own body sub-node. - Compile-time test verification works on a normal install. pytest
was only in thedevextra, sopip install 'rote-cli[dbos]'had no
test runner, the generated tests were silently skipped, and a compile
exited 0 with a failing generated test hidden from the repair loop.
pytest is now a dependency (>=9.0,<10), and a missing runner reports
the deliverable as unverified in the output and the compile report. - Repair rounds restate the rubric's write-once rules, so a repair no
longer rewrites compile-report.md in several one-line Edits. - A TypeScript impl typed with a closed input interface, which is what the
implement skill asks for, compiles on every TS runtime. DBOS-TS step
wrappers tookRecord<string, unknown>, and Cloudflare and Inngest
passedunknownvalues into the typed parameter, so following the
skill failed tsc. Each value is now asserted to the type the impl
declares for its key, and the DBOS-TS wrapper takes the impl's input
type, so a key the impl declares that the IR never sends, or the
reverse, still fails tsc. - Fixes from an end-to-end newcomer pass over main.
- Stub detection counts a function whose last top-level statement is an
unconditionalraise NotImplementedErrorand that never returns a
value, preamble allowed. BDR'szoominfo.enrich_batchvalidates its
batch size first and was missed, sorote emitreported three stub
modules instead of four androte runwould not refuse. A raise on
one branch, or a dispatch that returns on its real paths, is still
working code. - A missing required input is one line on python and dbos, as on
temporal: the app checks the input where it reads argv, prints
error: pipeline input is missing required field(s): ...and exits
2 (dbos before it launches), androte runreports just that line
instead of a traceback (about 5 KB on dbos). rote emit'snext:hint is a command that runs: it adds--input
with theinput.jsonbeside the pipeline (or one level up, the
examples' layout) when the pipeline has required inputs, and a
placeholder naming the fields when there is no file.rote examplealso copiesscorecard.mdandruns/, and rewrites
the copy's README so its commands and layout name the copy's own
paths, notexamples/...and/tmp/....- Missing-extra hints follow the install:
uv tool install 'rote-cli[<extras>]'(keeping the extras the tool already has) for a
uv tool install,uvx --from 'rote-cli[<extra>]'under uvx, pip
otherwise. - The stub refusal's fix is worded per language: TypeScript apps are
told to implement the TypeScript files, not to put Python modules
beside the pipeline. rote doctorlabels "api driver" and the "[api] extra" apart.- Temporal runs use the app dir as their working directory, so
expense-audit's report lands in the app like on python and dbos, and
the dev server's own startup WARN ("Cluster Id ... not a valid uuid")
is no longer printed. - The scorecard's sampled-steps row names its units (agent steps
before, pipeline nodes after) and says why the totals differ. - An invalid
\/escape in the Node MCP helper template no longer
warns on import (the emitted TypeScript is byte-identical). - The wheel-contents test builds with uv when available, then
python -m build, then pip, so it runs in a uv-made venv.
- Stub detection counts a function whose last top-level statement is an
- An agent loop now produces its declared output keys, on every
runtime. Downstream nodes read a loop's declaredoutputkeys
(process_invoices_loop.output.invoice_results), but the loop returned
{result, provider, iterations}. The next step raised KeyError on the
Python runtimes, gotundefinedon Cloudflare (a run reported
"undefined / NaN"), and DBOS-TS and Inngest failed tsc with TS7053.
The loop now hands its output back through asubmit_resulttool whose
schema is the declared output (the CLI lane uses--json-schema), and
the step returns exactly those keys, failing with the node and the
missing keys when the agent does not deliver them. A single-type output
is returned bare, and a loop with nooutputstill returns
{result: <text>}. Provider and turn count move to the usage log
(Python,iterations) and stderr (TS). The emitted TS loop functions
are typed by their declared keys. Python agent loops also honor
ROTE_BASE_URL_<ID>now, as the TS loops and every judge already did.
Every lane validates the submission against the declared schema
(types, required keys, no extra keys) and fails with one message, the
submit tool is a strict tool when the schema allows, and a loop that
ends without submitting gets one more turn withtool_choiceforced to
it. On the TS runtimes, a type the IR names but never defines
(InvoiceResult) is an alias in a new user-editablesrc/types.ts,
JsonValueuntil narrowed, androte emitnotes each one. The
anthropicfloor is now 1.15 (theapianddevextras, and the
python runtime's requirements.txt), and the lock moves from 0.116.0 to
1.15.0. - A
claude-clijudge or agent loop now gets a dollar figure. Its usage
rows were logged under the requested model, often an alias (sonnet)
that no price catalog lists, sorote runandrote eval --runcould
never price the subscription lane. The emitted inference helper now
logs one row per model from the CLI envelope'smodelUsage, under the
concrete id (claude-sonnet-5-5), with the CLI's owncost_usd.
Pricing takes that estimate as is, since it bills a 1h cache write at
twice the 5m rate a catalog publishes (a measured call: $0.0060 from
the CLI against $0.0040 from catalog rates). A row with nomodelUsage
keeps the requested id, and an alias is still left unpriced rather than
guessed.rote run's judge-usage line now ends with the dollar figure,
or says why there is none. rote mcp loginholds its callback port from selection to callback.
The port was checked for being free, then bound later by fastmcp's
callback server, after the browser had already been sent to it. Anything
that took the port in between (seen in CI: a concurrent login) killed
the login with EADDRINUSE instead of it using the next published port.
The login now binds the port while selecting it (33418 to 33421, both
IPv4 and IPv6 localhost), runs the callback server on those sockets, and
releases them on every path, so sequential logins never leak a port.rote runon an Inngest app reports the error that failed the run
(for exampleRoteMcpConfigError: no endpoint for MCP server 'vendor': set ROTE_MCP_VENDOR_URL ...), read from the dev server's run record
with its cause chain, instead of the last 500 characters of the app
log, which were a stack trace with the message cut off above them.- The MCP URL resolution order is documented as the code runs it:
ROTE_MCP_<SERVER>_URL, then the registry, then the IRurl
(Workers: the variable, then the IRurl). The registry module, the
IR field descriptions and docs/mcp-client.md said the IRurlcame
first. No behavior changed for runs.rote.mcp.resolve_server_url,
which encoded that wrong order and had no caller outside its own
test, is removed. - An emitted DBOS-TS app whose
DBOS.launch()fails now exits with the
error. It used to hang: the half-launched SDK kept its pools and timers
alive and no shutdown ran.rote run's throwaway Postgres for dbos-ts
also checks readiness over TCP now. The socket answered during the
image's init phase, just before the server restarts, which is how CI
reached the hang. A runner timeout now carries the app's output too. - Python no longer reads
NaN,Infinityor-Infinityin an MCP text
result as a float. They are not JSON, the TS helpers'JSON.parse
rejects them, and a non-finite float would not survive a JSON step
boundary, so both runtimes now keep the raw text. The cross-runtime
payload test also covers empty content, an empty text block, an error
result, structured content next to text, and an embedded resource, and
runs the Workers helper itself. The TS integer-precision limit (past
2^53) is documented indocs/mcp-client.md. - MCP auth hardening.
- The client metadata document check has a 5 second total deadline.
httpx's timeout is per read, so a host sending a byte every few
seconds held the login open indefinitely. rote mcp add --client-idrecords an issuer only from metadata that
describes this resource (RFC 9728 section 3.3) and only over HTTPS
(loopback excepted), for both the resource and the issuer. An
origin-level document on a shared host, or a plain-HTTP answer a
network attacker could write, no longer chooses which server the
client secret is offered to.--issuermust be HTTPS too.- The TS refresh grant and
rote mcp exporttake the client id and
secret from the same source, so a stored dynamically registered
client is never paired with the registry's pre-registered secret. - A stored login URL that does not parse (a bad port) parks the run
on both Python and TS instead of crashing it. - When rote sets aside a client from an older token file and the
server no longer allows registration, the login error says so and
names therote mcp add --client-idfix.
- The client metadata document check has a 5 second total deadline.
- python and dbos apps survive a fork after their first async step. The
background event loop added in this release lives on a thread, and a
forked child (gunicorn or uvicorn pre-fork workers serving an emitted
app) inherited the loop but not the thread, so its first async call
blocked forever. The child now resets the loop state through
os.register_at_fork, where the platform has it. - The TS MCP helpers (Node, Workers and the rote cloud binding) read a
text-only tool result by the Python helper's rules. They used to read
only the first text block, throw on text that wasn't JSON, and return
{}when there was no text, so one MCP step returned different values
on the TS and Python runtimes. Now every text block is parsed, non-JSON
text is kept verbatim, several blocks give a list, and no text gives
null, from one TS source spliced into all three helpers. - Running the test suite no longer rewrites committed files. Five
adapter suites emitted the BDR pipeline straight into
examples/bdr-outreach/expected/runtimes/, and the dbos and temporal
ones replaced the example's realextracted/implementations with
stubs, so everypytestleft a dirty tree. They emit into temp dirs
now,tests/test_committed_snapshots.pydiffs a fresh emission
against each snapshot, and the suite fails if anything under
examples/changes during a run. - An optional pipeline input can be omitted on every runtime. The
Python runtimes read input fields aspipeline_input["f"], so
ops-report's optionalreport_dateraisedKeyErrorwhen a caller
left it out. An omitted optional field is now left out of the step's
payload, so the impl's default (or a judge schema's default) applies,
as on the TS runtimes. Every runtime now checks the input on entry and
fails withpipeline input is missing required field(s): ..., or
pipeline input must be a JSON objectfor a run with no payload, where
TS used to passundefinedthrough. Temporal raises a non-retryable
ApplicationErrorand Inngest aNonRetriableError, so a bad input
fails once instead of retrying. New derived IR properties
Pipeline.required_input_fields/optional_input_fieldsdefine the
split, and a field listed as both required and optional is rejected.
A long Node failure excerpt (DBOS-TS) keeps theError: ...line that
a Node stack prints above its frames. - An agent loop's local tools now return their result to the model as
JSON text. The SDK tool runner iterated a dict result, so the model saw
only its keys. - Stub detection no longer refuses a filled-in module that only mentions
the[rote stub]marker in a comment or docstring: the marker counts
in Python only as araiseargument, and in TypeScript only outside
comments. Agent-written stubs that are class methods (a body of only
raise NotImplementedError) are now detected;@abstractmethods are
not stubs. - Token refresh reaches authorization servers off the MCP server's
origin. Before discovery the SDK refreshes at
<resource-origin>/token, which 404s for a path issuer or another host,
so the provider fell into a full authorization and an emitted workflow
parked on a credential that was fine. The CLI and the emitted Python
helper now send the refresh to the storedtoken_endpoint. - A stored login is sent only to the endpoint it was made for. Token
files are keyed by server name, so afterrote mcp add <name> <new-url>or aROTE_MCP_<SERVER>_URLoverride the old endpoint's
bearer token went to the new host. A file for another endpoint is now
treated as absent: the CLI refuses with arote mcp loginhint, and
the emitted Python and Node helpers park the run. - Stored clients are bound to their authorization server. The SDK
binds only throughclient_info.issuer, so a client stored without one
matched every issuer and could be offered to a repointed server. The
stores now fill it from the file'sissuer, login writes both, and an
interactive login re-registers a client that has no issuer at all. - The emitted Python helper never registers a client from workflow code;
a server that wants a new registration parks the run like any other
dead credential. - Park-on-auth works again under fastmcp 4. A 401 now reaches the
caller as a status-freeMCPError, so the emitted MCP helper's
httpx.HTTPStatusErrorcheck never matched and an expired credential
failed the run instead of parking it. The helper now reads the status
from an httpx2 response hook on its own transport. - MCP step results are plain JSON. The emitted helper returned
fastmcp's hydratedresult.data, which for a typed output schema is a
generated class that DBOS cannot pickle and that renames non-identifier
keys. It now returns the wirestructured_content(unwrapping
fastmcp's{"result": ...}wrapper only when marked), else the parsed
text content. - TypeScript MCP steps read fastmcp results the way Python does. The
Node and Workers helpers returnedstructuredContentas-is, so a
list-returning fastmcp tool was a list in Python and{result: [...]}
in TypeScript. Both now remove the marked wrapper through one spliced
function; a cross-runtime test runs both helpers against one server. - A cancellation or interrupt that arrives after a 401 now cancels the
MCP step instead of parking it as an auth failure. rote mcp loginand token refresh no longer end onping, which the
sessionless 2026-07-28 protocol does not route.rote servekeeps live tool-list updates on fastmcp 4: handshake-era
clients still getnotifications/tools/list_changed, and sessionless
2026-07-28 clients get it on asubscriptions/listenstream (the
server now advertisestools.listChangedon that era).--runtime pythonand--runtime dbosnow runasync defextracted
functions. Steps called every implementation directly, so an async one
(BDR's hand-written clients are async) returned an unawaited coroutine
and the run died downstream with'coroutine' object is not subscriptable. Steps now go through an emitted_call_impl, and all
async work in those runtimes (async impls, dbos MCP steps, legacy
async judges) runs on one background event loop per process instead
ofasyncio.runper call. So a module-level async client (an
httpx.AsyncClient) keeps working across fan_out elements and
retries, and emitted steps work when called from inside a running
loop. Temporal shares the same helper's async form, which now also
awaits a coroutine returned by a plain function.rote emit(andanalyze/compile) no longer warns "not
authenticated, the run will park" for an MCP server that needs no
auth. rote now learns each server's requirement and keeps it in
mcp-auth-learned.jsonbeside the registry (0600, atomic writes),
keyed by server name and normalized URL so repointing a name
invalidates it.rote mcp loginrecords it: a server whose
tools/listsucceeds with no challenge (the OAuth provider discovered
nothing) isnone. Login used to fail there with "completed without
storing a token", and still does when a challenge happened but no
token landed.rote mcp add --auth oauth|nonesets it explicitly. A
server learned asnonereports "no auth required" (dated in
mcp listanddoctor) with no recommendation, and live compiler MCP
tools include it. A tokenless server with nothing learned reports
"auth unknown", worded as "if it needs auth, the run will park". Only
a server known to need OAuth gets "will park". Evidence overrides a
learnednone: a token for the endpoint wins, and an observed auth
park recordsoauth. A token file left by the endpoint a name used to
point at no longer counts as a login, inloginor in the status. The
advisory's"unknown"state for an unreadable registry is now
"registry unreadable".- Version compatibility of
~/.config/rote/mcp.json. Released rote
versions up to 0.14.0 load the registry with unknown fields rejected,
so a field a newer rote writes breaks every older rote on the same
machine (a cacheduvx --from rote-cli, the plugin'suv run). That
is why learned auth lives in its own file and the registry schema is
unchanged by it.rote mcp add --client-id ... --issuer ...(new in
this release) does write anissuerfield, so a registry with an
issuer-bound client is unreadable to 0.14.0 and older until that
entry is removed. From this release on, the registry and the sidecar
keep fields they do not know and read unknowntransport/auth
values as unsupported or unknown instead of rejecting them, and every
writer keeps a higherversionthan its own. - README and AGENTS.md accuracy: the compiler skill has six reference
files, not four;rote runexecutes all six runtimes (AGENTS.md said
python/dbos only); the slow e2e suites do run in CI; the BDR run flags
three mandatory nodes, not four. - A
fan_outnode can fan over a pipeline-input list
(invoice: pipeline.input.invoices). The element resolver only knew
upstream node outputs, so emission raised, androte compilecrashed
with a traceback after the agent had been paid. - No failure after a paid agent run loses the IR. An emission error
prints the cause and therote emitcommand that retries it, and an
IR the validator rejects is kept in the output dir (an update's goes to
<out>.rejected, leaving the previous output alone) instead of being
deleted with the temp work dir. - Emitted Python agent loops report the turns that actually ran. The
vendor-SDK lane returnedmax_iterationsasiterations, so every loop
looked saturated, and it logged only the final request's tokens to
$ROTE_USAGE_LOG. It now counts the assistant messages the tool runner
produced (the TS twin's semantics) and sums usage over every turn. The
claude -plane reportsNoneinstead of0when the envelope carries
nonum_turns. uvx rote-cli ...works. The wheel installed only arotecommand,
and uvx runs the command named after the package, souvx rote-cli servefailed with executable not found. The package now installs
rote-clias a second command for the same CLI.--runtime temporalnow emits anextracted/package, the same way
dbos and python do: the agent's real modules from beside
pipeline.yamlwhere they exist, IR-derived stubs otherwise. Before,
activities importedexpected.extracted.*, which resolves only inside
the BDR example. Sorote emit --jsonreported no
unimplemented_stubs,rote runcould not import a single module,
and an agent loop with tools lackedextracted/_rote_mcp.py. Plain
(non-async) implementations now run in a worker thread instead of
being awaited, which used to raiseTypeError.- A judge whose
inputs:cannot satisfy its input schema is rejected
at load time. Every adapter builds a judge's payload from its
inputs:keys alone, so arequiredproperty nothing binds failed
validation on every call, and nothing caught it before run time. A
bound param the schema does not declare is rejected too, whatever
additionalPropertiessays: a binding the judge's contract cannot
name is a mistake either way. Found by a real eval: deal-monitor's fanned
classify_thread_stepbound onlythreadwhile its schema also
requiredaccount_name, so all of its calls failed. The new
Pipelinevalidator names the node, the params, and the schema, and
on afan_outnode names the element param through the same
fan_out_element_paramthe emitters use (moved torote.irso the
IR and the adapters share one resolver). Loop-body sub-nodes and
legacysignature:-only judges are exempt. Four committed IRs failed
it and are fixed: deal-monitorexpected(the judge now takes the
wholeMatchedThread, which carries the account), deal-monitor runs
2026-07-18-rubric-v2and2026-07-18-first-agent-run(fanned
judges had their element flattened into top-level fields), and BDR
run2026-04-26-signature-spec(personalize_emailbound nothing).
The compiler rubric now says a fanned judge's input has one property
for the whole element, and shows the wrong shapes it produced. - Zod schemas honor
additionalPropertiesthe way Pydantic does.
The TypeScript emitters put.strict()on every object, while the
Python emitters forbid unknown keys only under
additionalProperties: false. An upstream object carrying one
undeclared field therefore passed on dbos and python and failed every
judge call on Cloudflare, Inngest and DBOS-TS. Objects now get
.strict()only when the schema is closed and Zod's default strip
otherwise (Pydantic'sextra="ignore"), and a property-less object is
z.record(z.string(), z.unknown())to matchdict[str, Any]. A new
slow test runs one schema through the emitted Pydantic model and real
zodunder Node and requires the same verdicts and parsed values. - deal-monitor's
expectedIR no longer setstemperature: 0.0on its
three judges. Current Claude models reject the parameter, and the
rubric already forbids it. A test now keeps everyexpected/IR free
of it; historicalruns/snapshots stay as compiled. - The subscription lane no longer carries the user's own Claude Code
setup. Emitted judges and agent loops onclaude-clistill loaded the
user's claude.ai connectors and plugin MCP servers, and the auto-memory
index of the repository they ran from, despite--tools "" --setting-sources "". Measured on Claude Code 2.1.302, a one-line judge
was 30.3k tokens and $0.183 a cold call; with--strict-mcp-configand
auto memory off it is 1.25k. Agent loops take the same flags plus their
own--mcp-config, and now also--tools ""(it removes only built-in
tools), so a loop sees its declared servers and nothing else: a
two-turn loop went from 120k tokens / $0.38 to 2.8k / $0.012 with its
MCP tool still bound. Both paths share one flag tuple. rote compilewith the claude driver passes--strict-mcp-config.
The driver wires no MCP, yet the user's connectors put 357 tools in the
first request, past 200k tokens, and a subscription without usage
credits rejected the compile before it started. Strict, the request is
70.7k tokens.$ROTE_USAGE_LOGrecords all four token buckets
(input_tokens,output_tokens,cache_write_tokens,
cache_read_tokens), androte eval --runprices all four at live
rates. A prompt-cachedclaude -pjudge used to log 3 input tokens.
A cache bucket with no published rate marks the model unpriced instead
of guessing.rote run's judge-usage line counts cache tokens as input.rote evalprices afan_outnode per element. The estimator gave
every non-loop node one call per run, so a fanned judge (BDR's
personalize_email, deal-monitor'sclassify_thread_step)
understated the after-side dollars by the fan width. The width comes
from the compiler'seval.yaml(iterationson the node's step)
when it records one, else from the newfan_out_elements_per_run
prior (default 10,--priorto override). The scorecard's
Assumptions section lists each fanned node's width and source. Elements
run concurrently, so they multiply tokens and dollars but not wall time.- First-run CLI output, from a fresh-install test of 0.13.0.
- Every install hint now says
rote-cli[...](rote doctor, the api and
openai-api drivers,rote serve, the serve backends). Therote
package on PyPI is an unrelated library. All hints come from
rote.extras.install_hint; a test fails on a barerote[, or a
hand-typedrote-cli[, anywhere insrc/outsiderote/extras.py. rote runchecks the runtime's extra before starting (dbos, temporal)
and prints the install hint instead of aModuleNotFoundError
traceback. When an app subprocess dies on a missing module that an
extra provides, the error ends with the same hint.rote runrefuses an emitted dir whoseextracted/modules are still
stubs, listing the files, before any retries run. Previously a stub's
NotImplementedErrorwas retried for about 10 seconds first.
--allow-stubsstarts the run anyway. A stub is a function that raises
with the[rote stub]sentinel rote now puts in every emitted stub, or
(Python) one whose whole body israise NotImplementedError, so a real
module that raises it on an unsupported branch is not refused.- A failed runner subprocess now shows the end of its output, where the
exception is, not the first 800 characters of startup logs cut off
mid-word. - Plain-text
rote emitandrote compileprint the stub count, each
stub path, and the nextrote runstep. Text,--json, and the
preflight read the same manifest-based stub list. Output whose code
imports its implementations from outside the emitted dir says the stubs
are "not tracked" (and--jsoncarriesstubs_tracked: false) instead
of reporting none. --jsoncommands that fail without printing a result now write
{"ok": false, "exit_code": N, "error": "..."}to stdout, keeping the
exit code. This is enforced once inmain()for every--jsoncommand.rote run --helplists the runtimes it actually runs (all six), and
--modelhelp reads the default model from the driver constant.- Emitted READMEs (dbos, dbos-ts, python) document the env knobs of the
pipeline's real judges, or say there are none, instead of a
ROTE_MODEL_VET_CONTACTexample on pipelines with no judges. rote configcolumns line up for every key, includinginference.
- Every install hint now says
- A missing MCP endpoint (no
ROTE_MCP_<SERVER>_URL, no registry entry,
no URL in the IR) now fails the step at once on every MCP runtime,
naming the variable to set. It used to be retried like a network
error: DBOS ran all 4 attempts and reported only
DBOSMaxStepRetriesExceeded. The helpers raise a new
RoteMcpConfigError, whichshould_retry(DBOS Python),
shouldRetry(DBOS-TS),NonRetriableError(Inngest) and
NonRetryableError(Cloudflare) now exclude from the retry budget,
the same way they already excluded auth failures. It does not park:
the fix is the variable and a restart, not a login. Agent loops get
the same treatment: tool discovery that cannot reach a pinned server
for lack of an endpoint raisesRoteMcpConfigError(TS and Python),
and each runtime's loop step skips its retries on it (temporal via
non_retryable_error_types). The Python loop also wires a pinned
server that resolves from itsROTE_MCP_<SERVER>_URLalone, which
it used to ignore. rote runon a Cloudflare app reports the step error that failed the
run. Local dev's status only saysWorkflowFatalError, so the runner
now reads the real error back from the wrangler log. A step sleeping
between retry attempts after the last gate payload was delivered is
no longer mistaken for a park and failed with "every gate payload has
been delivered"; the runner waits for the run's real outcome, reports
the newest step error, and still fails fast on a gate that never
consumes its payload while nothing is retrying.