Migrating from 0.6.x to 0.7.0
What changed between Routecraft 0.6.0 and 0.7.0, and how to update.
0.7.0 is the durable release. A capability can defer mid-pipeline and continue days later from another transport, an agent can do the same in the middle of a conversation, and a running instance can be driven, watched and reached over HTTP: from a terminal, from an editor, or from another instance. Almost all of that is additive. What follows is every change that can break a 0.6 project, in the order most projects meet them.
- Time options lose their
Mssuffix and takeDuration.intervalMsisinterval,timeoutMsistimeout, and so on; a stale name fails the build withRC5003naming the replacement. Numbers keep working, and'5m'is now accepted everywhere. Section 1. - Listeners are declared once, under
servers.http.port/http.hostandmcp.port/mcp.hostare gone, listener events are renamed, the per-routehttp({ auth })option is removed,mcpPluginover HTTP needs an explicitresource.urlin production, and MCP and ACP mounts refuse a browserOriginor an unknownHostwith 403 until you list them. Section 2. - Deferral is new. Nothing to migrate: 0.6 had no way to pause an exchange durably. Everything that moved during the canary is in the canary notes. Section 3.
- Lifecycle. A context is single-use,
plugin:startedmoves to the newstart()phase,Route.signalchanges meaning, and shutdown drains before it abandons. Section 4. - Agents and tools.
currentTime()andrandomUuid()are removed,checkpointIdisdeferralId, agent files load by frontmatter name, andcraft chatis gone. Section 5. - MCP. A dropped exchange is an error result, the advertised output schema is enforced, and primitive outputs carry
structuredContent. Section 6. - The
http()client. A 10 MB body cap, a narrowerbodytype, and a lower-casecontent-typeno longer doubles the header. Section 7. forward()carries the caller's headers, which a strict target may now reject. Section 8.html()text extraction keeps whitespace and escaped markup. Section 9.- Smaller breaks.
.input()validation throwsRC5065rather thanRC5002,timer({ exactTime })andtimePatternremoved,ops.auth: falsemeaningful, thenable and callable schemas validate, ArkType deferral digests change,.retry()reaches continuations. Section 10.
Routes built only from the DSL with framework adapters typically need section 1 (the rename is mechanical, and the build tells you where), section 2 if they serve HTTP or MCP, and section 3 if they defer. The rest is for adapter authors, store authors and advanced integrations, plus the notes for anyone who ran a 0.7.0 canary (section 11). Section 12 lists what is new, for context.
1. Time options: the Ms suffix is gone
Every option a route author writes that means a span of time now takes Duration, which is number | "5m": a bare number is milliseconds, as before, and a string names its unit. The Ms suffix came off every such option, because a name ending in Ms that accepts "5m" lies. Values the framework reports are unchanged: durationMs on events, backoffMs on route:retry:attempt and ageMs on ops responses stay millisecond numbers, because they are data rather than configuration.
Existing numeric values keep working under every new name, so the migration is a rename and nothing else.
Before (0.6.x):
import { craft, noop, timer } from "@routecraft/routecraft";
craft()
.id("poll")
.from(timer({ intervalMs: 60_000 }))
.retry({ maxAttempts: 3, backoffMs: 1000, maxBackoffMs: 10_000 })
.to(noop());
After (0.7.0):
import { craft, noop, timer } from "@routecraft/routecraft";
craft()
.id("poll")
.from(timer({ interval: "1m" }))
.retry({ maxAttempts: 3, backoff: "1s", maxBackoff: "10s" })
.to(noop());
Four things beyond the rename:
- A stale name is refused at runtime, not only by TypeScript.
timer({ intervalMs: 60_000 })from plain JavaScript, through anas any, or through an options object spread in used to run silently on the 1000ms default. Every authored options surface now rejects a key ending inMs, the options removed for their own reasons (timer({ exactTime }),timer({ timePattern }),retry({ exponential })), and the name a straight desuffixing would guess (timer({ jitter })andcron({ jitter }), which point atmaxJitter), each withRC5003naming what to write instead. The context's own config (shutdown, eachservers.<name>,telemetry.sqlite) is checked in the constructor. jitterontimer()andcron()ismaxJitter, not the barejittera straight desuffixing would give: each delay is drawn uniformly from[0, maxJitter), and.retry({ jitter })is a fraction in[0, 1], which stays as it was. One name meaning two types on two operations is the defect this change closes..throttle({ per })accepts aDuration. The unit words keep their meaning, and a bare number is milliseconds as everywhere else aDurationis accepted:per: 60is a 60ms window. Writeper: "60s"orper: "minute".craft start --timeoutaccepts a duration string (--timeout 30s); a bare number is still milliseconds.
2. Servers and mounts
A listener is declared once under servers and selected by name from every surface that serves HTTP: routes, MCP, ACP and the ops surface can share one port on distinct paths, or each gets its own.
Before (0.6.x):
import { defineConfig } from "@routecraft/routecraft";
import { mcpPlugin } from "@routecraft/ai";
export default defineConfig({
http: { host: "0.0.0.0", port: 8080 },
plugins: [mcpPlugin({ transport: "http", host: "0.0.0.0", port: 8081 })],
});
After (0.7.0):
import { defineConfig } from "@routecraft/routecraft";
import "@routecraft/ai"; // augments CraftConfig with mcp
export const craftConfig = defineConfig({
servers: { public: { host: "0.0.0.0", port: 8080 } },
http: { server: "public" },
mcp: { server: "public", path: "/mcp" },
});
Use a separate server name to give any surface a dedicated port. What else changed with it:
-
Listener events are
server:listening,server:failedandserver:closed, each carrying the server name.plugin:http:server:listening/plugin:http:server:closedandplugin:mcp:server:listeningare removed; subscribe toserver:listeningon the MCP transport's named server instead. -
The per-route
http({ auth: "required" | "optional" | "skip" })option is removed. A mount decides authentication:http: { mounts: { api: { path: "/api" }, public: { path: "/", auth: false } } }walls/apiunder the server validator while the catch-all stays open, and a route picks a surface withhttp({ mount: "api", path: "/orders" }). A route on an open mount that needs identity declares.authorize(), which forces verification through the server validator. The former"optional"personalisation mode has no equivalent. -
One
authvocabulary on every mount. Unset inherits the server validator as a wall when one exists; a config is the mount's own wall;falseremoves the wall and keeps the inherited validator reachable, so a route's.authorize()still pulls identity through it. On the MCP mountauth: falseno longer means "credentials are never inspected": a valid token attaches a principal to tool calls and an invalid one is treated as absent.serverAuthConfiguredis removed fromWebIngressandHttpMountContextin favour of the resolvedHttpMountAuthfacts (configured,own,optedOut,walled). -
serveris a mount property on http. Each entry undermountsnames its own listener (default"default"); the plugin's top-levelserverremains only as the single-mount shorthand and is refused alongsidemounts. -
mcpPlugin({ transport: "http" })requires an explicit HTTPSresource.urlunlessNODE_ENVis exactlydevelopmentortest; an unsetNODE_ENVcounts as production, socraft runorcraft starton a laptop refuses to boot withRC5003. Previously an omittedresource.urlsilently advertised the bound address. For local work over plain http, run withNODE_ENV=development(for example"dev": "NODE_ENV=development craft start"); in production, setresource.urlto the HTTPS URL clients reach through your proxy. -
MCP and ACP mounts check
HostandOriginbefore CORS and auth, and answer 403. Two things that worked on 0.6 now need configuration:- Browser clients need
browserOrigins. A request carrying anOriginheader is refused unless that exact origin is listed on the mount, and that includes local browser tools such as the MCP Inspector (mcp: { browserOrigins: ["http://localhost:6274"] }). A permissivecorssetting does not admit a browser any more; the two are independent. Clients that send noOrigin(editors, server-side and desktop MCP clients, curl) are unaffected. - A reverse proxy's public hostname needs
allowedHostnames. The bound hostname is trusted, and loopback and wildcard binds also acceptlocalhost,127.0.0.1and[::1]. A proxy that preserves its publicHostneeds it listed underservers.<name>.allowedHostnames; MCP also trusts the hostname of its ownresource.url.ForwardedandX-Forwarded-Hostnever establish trust.
A request the Host or browser-origin check refuses emits
server:request:rejectedwith the server, the mount and the reason (hostororigin); a CORS refusal answers403without it. See Securing capabilities for the full rules. - Browser clients need
-
auth:rejectedreasons are one bounded vocabulary (invalid_token,unsupported_scheme,infrastructure,expired,insufficient_scope), the missing-credential reason ismissing_header, and bothauth:rejectedandauth:successcarrysourceashttp,http:<name>ormcp. A bearer verification that fails on the server side (JWKS unreachable) answers500rather than401, so a client keeps its cached token through an IdP blip. -
Mount paths are static canonical prefixes: no
?,#,:paramsegments, empty segments or percent-encoding.mcpPlugin({ path })is held to the same contract and rejects"/", which was previously remapped to/mcp. The validator is exported asnormalizeStaticPathPrefix. -
Listeners reap idle connections after 255s, tunable per server with
idleTimeout(refused above 255s rather than clamped, because Bun caps it there). Mounts that hold quiet long-lived streams exempt their requests, andmaxStreamingRequests(default 500) caps the streams one listener carries. -
A declared server with nothing mounted fails the boot, naming every mount and where it landed, so a plugin that failed to apply is told apart from a misspelt server name.
3. Deferral, new in 0.7.0
Nothing to migrate here. 0.6 had no way to pause an exchange durably and continue it later, so .defer(), .resume(), the deferral store and the deferral config block are new surface rather than changed surface.
What they are, in one place:
.defer()defers an exchange into a store and answers the caller at once with aDeferredacknowledgment,{ status, deferralId, token, schema?, expiresAt? }..resume()revives it by signed token, from any transport, at the step after the deferral. A route with a reachable.defer()has output typeOutput | Deferred.- Deferrals expire after
deferral.defaultTtl, which is72hunless you set it. An overdue one re-enters the route's error channel withRC5047. Settled records are purged afterretention,90dby default. Either takes"never". .resume({ authorize })is the one policy point. Without it the door is bearer: any holder of a valid token may resume, which is why securing a resume ingress is something you do rather than something you inherit.- Durability starts at a declared
.defer(). A process that dies at step four of a route that never deferred loses that exchange.
If you ran a 0.7.0-canary-* build, this surface was called something else for most of the canary. See section 11.
4. Lifecycle and shutdown
- A context is single-use.
context.start()aftercontext.stop()refuses withRC1004instead of resolving readiness over routes whose controllers are gone. Build a fresh context from your config; the process is the restart unit, andcraft runalready behaves this way. Two concurrentstart()calls collapse into one boot. context.stop()resolves{ forced, pending }instead ofvoid, andcontext:stoppedcarries the same payload. A clean stop reportsforced: falseand an emptypending; anawait context.stop()call site is unaffected, and a handler typed against the old empty payload keeps compiling.plugin:startedmoves. Plugins gain astart()phase betweenapplyandteardown, running after every route has signalled readiness. The apply-phase events areplugin:applying/plugin:applied;plugin:starting/plugin:startedbracket the new phase, so a subscriber toplugin:startedkeeps compiling and firing, later than before.CraftContext.whenStarted()resolves once every hook has finished, which is whatstartAndWaitReady()awaits.teardowntakes a second argument,{ partial, started }. Additive:partialsays the context never finished starting,startedsays whether this plugin's ownstart()ran.Route.signalmeans "in-flight work is abandoned". It used to fire at the start of shutdown; that meaning moved to the newRoute.intakeSignal. Code that readroute.signalto notice a shutdown keeps compiling and silently stops reacting to a graceful one: readroute.intakeSignal. An out-of-repo implementation ofRoutemust addintakeSignal,abortExecution()andinFlightCount.- Shutdown drains. The first signal closes intake and lets in-flight exchanges finish; only the forced stage abandons them, at
shutdown.timeout(default 30s; set it below your platform's kill timer). At the deadline one warn line names the routes still working, execution is abandoned, and the process exits non-zero. A non-positive or non-finiteshutdown.timeoutis refused withRC5058. A route disabled at runtime drains the same way, bounded by.enabled({ drainGrace }). - A second Ctrl+C no longer forces an exit. A repeated SIGINT or SIGTERM during the graceful stage is ignored. SIGQUIT (Ctrl+Backslash) or SIGBREAK (Ctrl+Break on Windows) forces an immediate exit, before or during it. A container whose
STOPSIGNALisSIGQUITand expects a drain gets a force exit instead; keepSIGTERMas the stop signal. See shutdown helpers. - A lifecycle hook must not
awaitits ownctx.stop(): shutdown now waits for an in-flightapply()orstart()before teardown, so the awaited form waits for itself.throwto abort the boot, or callctx.stop()without awaiting it. route:startedfires earlier for a callable source, when the route invokes the callable rather than on its first message, so a quiet polling route no longer delays readiness by the 30s backstop.- A context whose routes are all disabled no longer auto-stops, because a disabled route has not completed.
5. Agents and tools
5.1 currentTime() and randomUuid() are removed
Both were fn factories you assigned a tool name in agentPlugin({ functions }). Declare the fn inline under the same name and every tools([...]) reference keeps working:
Before (0.6.x):
import { agentPlugin, currentTime } from "@routecraft/ai";
agentPlugin({ functions: { CurrentTime: currentTime() } });
After (0.7.0):
import { agentPlugin } from "@routecraft/ai";
import { z } from "zod";
agentPlugin({
functions: {
CurrentTime: {
description: "Current date and time in ISO 8601.",
input: z.object({}),
handler: () => new Date().toISOString(),
tags: ["read-only", "idempotent"],
},
},
});
The randomUuid() replacement is the same shape with handler: () => crypto.randomUUID() and tags: ["read-only"]. directTool() is now the only fn builder the framework ships.
5.2 FnHandlerContext
checkpointId is renamed deferralId, and defer is a required member, so a hand-built handler context must provide one. makeFnHandlerContext and testFn both do, and @routecraft/testing's TestFnHandlerContext includes a structural defer that returns the sentinel shape without deferring anything.
5.3 Agent files
agents() walks the agents/ folder recursively, matching Claude Code's subagent convention, and an agent's identity is its frontmatter name: the filename no longer has to match, so nothing that loaded before stops loading. Three things can: duplicate names throw, naming both files; a directory holding AGENT.md is one agent and its name must match the directory; and skills: frontmatter has new semantics, declaring where an agent's skills come from (local paths, npm: refs) rather than naming blocks, which is what the key removed in 0.6 did. disallowedTools without tools is rejected at load, because a deny list against inherited defaults cannot be honoured.
5.4 craft chat is removed
With no shim, alias or pointer. The interactive interface is an editor speaking the Agent Client Protocol: craft acp is the bridge, and acp: on defineConfig serves it. See talk from your editor.
6. MCP
- A route that drops the exchange answers an error result. A
.filter()rejecting, a.choice()matching no branch or a handler returningrecovery.drop()used to publish the caller's own request as the tool's result. Such a call is nowisError: trueunderAI2002, emitsplugin:mcp:tool:declinedand logs at warn;plugin:mcp:tool:failedkeeps meaning a failure. A route that should return a value on that path needs a branch that produces one, or.error()recovering with a body. - The advertised
outputSchemais enforced. A body the route's own.output()already validated is not re-checked; a result that reached the server without passing through it (a tool registered directly, a deferral) is validated at the surface and refused withAI2001if it contradicts the promise.validateAgainstandwasOutputValidatedare exported for any request/reply adapter enforcing a schema at its own boundary. - Primitive and array outputs carry
structuredContent. A tool declaring.output({ body: z.string() })used to advertise a schema and then omitstructuredContent, which a spec-compliant client rejects. The value is now published in the envelope the protocol era expects ({"result": "42.50"}on the 2025 era, unwrapped on 2026). A client reading only the text block sees no change; an object output is byte-identical. A deferring tool advertisesoneOf: [Output, Deferred]and its deferral emitsplugin:mcp:tool:deferredrather thancompleted. McpIcon.sizesisreadonly string[], and the default icon set gains a 96x96 PNG per theme, growing from 772 B to 3840 B per inheriting tool;icons: []opts out.mcpPlugin({ auth: false })now attaches a principal when a valid token is presented, per the mount vocabulary in section 2.
7. The http() client
- The response body is capped at 10 MB. A declared
Content-Lengthabove the cap is refused before the body is read; otherwise the count is checked as bytes arrive. Exceeding it fails withRC5061naming the option and the size; the body is never truncated to fit. RaisemaxBodySizeon a call that legitimately moves more, or passInfinityto opt out. bodyis typed asHttpRequestPayload(string, number, boolean, null, object,Uint8Array,ArrayBuffer,URLSearchParams,FormData). In practice onlybigintandsymbolbodies stop compiling, and in exchangebody: (ex) => ...getsextyped as the route's exchange with no annotation.- A lower-case
content-typeheader no longer doubles up. The client used to addContent-Type: application/jsonwhen it found no header spelled exactly that way. - New and additive:
redirect: "follow" | "manual" | "error"(a 3xx under"manual"does not tripthrowOnHttpError;isRedirectis exported) andresponseBody: "bytes"for aUint8Arraythat arrives intact.
8. forward() carries the caller's headers
forward() built the forwarded exchange with no headers, so a target's .authorize() refused it with RC5012 or ran it with no authority at all, and the correlation chain broke at the boundary. It now passes the caller's headers by reference, which brings it to parity with a direct() destination. Two consequences to check:
- A target route with a strict
.input({ headers })schema now sees the caller's headers and may reject a forward that previously passed. - Forwarding a principal whose
expiresAthas passed surfaces the expiry error rather thanRC5012.
Every route ingress now mints a fresh routecraft.id rather than inheriting one, and drops routecraft.split_hierarchy, so a forwarded or direct()-dispatched exchange never collides with its caller in a store keyed by exchange id or joins the caller's aggregation. Route.getForward() (internal) takes the calling exchange as a required argument.
9. html() text extraction
extract: "text", "innerText" and "textContent" no longer run a tag-stripping regex over text cheerio had already decoded. Two things change, on every role that extracts:
- Whitespace inside the match survives. Only the ends are trimmed, per matched element, so a
<pre>keeps its newlines and ordinary page indentation is no longer flattened to single spaces. - Escaped markup survives.
Array<string>extracts asArray<string>rather thanArray. The value is decoded and unsanitised, so escape it at the sink before writing extracted text into HTML or a line-structured format.
You are affected if a route compares extracted text to a literal, keys or hashes on it, validates it against a schema, or writes it to a line-oriented sink. Collapse in the route where you want the old shape:
import { craft, direct, html, noop } from "@routecraft/routecraft";
craft()
.id("card-text")
.from(direct())
.transform(html<unknown, string>({ selector: ".card", extract: "text" }))
.transform((text) => text.replace(/\s+/g, " "))
.to(noop());
<style> and <script> subtrees are still removed. extract: "html", "outerHtml" and "attr" are unchanged.
10. Smaller breaks
timer({ exactTime })andtimer({ timePattern })are removed.timePatternwas never read;exactTimeanchored only the first fire and then ran oninterval. Usecron("0 9 * * *", { timezone: "Europe/Amsterdam" }), which pulls thecroneroptional peer.ops.auth: falseis no longer refused. It carries the server plugin's meaning: no validator is effective for the ops mount, thehealth.detailsgate closes, and a scope-gated tier has nothing to check against and fails the boot..input()validation throwsRC5065, notRC5002.RC5002meant both a caller's malformed payload and the instance failing its own contract (.output()validation, a mid-pipelinevalidate(), an empty aggregation), so no transport could answer the two differently. The route's.input()body and header schemas now raiseRC5065; everything else keepsRC5002. A route-scope.error()matchingrc === "RC5002"to recover a bad payload matches"RC5065"now; one catching an output orvalidate()failure is unchanged. The failure is raised at the same chain position, is routable through.error()the same way, and is stillretryable: false. The visible payoff is on the ops dispatch mount, which answers400 {"error":"bad request","code":"RC5065","message":...}where it used to answer500 {"error":"dispatch failed","code":"RC5002"}with no message, socraft execnow names the field and the rule that rejected the request.- A schema whose
validate()returns a thenable now validates instead of passing the caller's input back as accepted. A body such a schema rejects now fails, withRC5065on.input()andRC5002on.output(); a schema that never settles hangs the validation instead of silently passing, and nothing bounds that wait. Avalidate()returning a non-record fails with a message instead of crashing. - Callable schemas (ArkType) are accepted wherever the framework asks "is this a Standard Schema", and a deferral deferred under one now hashes its rendered JSON Schema. The digest changes for ArkType only: an ArkType deferral from before the upgrade resumes into
RC5048(continuation changed), is denied, and re-asks its approver. Settle or drain those before upgrading if a re-ask is disruptive. Zod digests are byte-identical. - The deferral counter refuses corruption with
RC5057instead of resetting to zero, which could have re-derived an id an earlier deferral already used. - An event handler returning a bare thenable is no longer logged as having thrown.
@routecraft/testing'sstartAndWaitReady()no longer rejects when a startup exchange fails while another source is still coming up, and its readiness gate settles a route that starts or is disabled.apiKey()gainsscopesand is refused alongsideverify;apiKey({ scheme })was already removed in 0.6.
11. Canary-only notes
Nothing released is affected by these; they matter only if you ran a 0.7.0-canary-* build.
- The
surface()params callback no longer takessessionId; remove thesessionId: ''placeholder from every callback. The adapter fills in the running turn's session. - The SQLite session schema is version 2 and keyed by session id alone. A database written by a canary or a recent
maincheckout loses its stored conversations on first open. startedByon theagent-sessionsmanagement resource isowner.sessions: { store }anddeferral: { store }resolving to the same file are refused at boot, naming both settings, and every SQLite file carries the identity of the store that owns it..suspend()is.defer(), and a suspension is a deferral. The action is named for what it does: nothing is scheduled and no worker waits.ex.suspensionisex.deferral, the acknowledgment'sstatusis"deferred"and itssuspensionIdisdeferralId,SuspensionStoreisDeferralStore(withMemoryDeferralStoreandSqliteDeferralStore),suspension: { ... }isdeferral: { ... }, andROUTECRAFT_SUSPENSION_STOREandROUTECRAFT_SUSPENSION_SECRETareROUTECRAFT_DEFERRAL_STOREandROUTECRAFT_DEFERRAL_SECRET..resume()keeps its name, and so doschema,ttl,metaand every member of the affordance..defer({ expect })is.defer({ schema }), and the option is optional. The rename carries throughctx.defer(),DeferError,testFn's structural defer, the acknowledgment's wire field, the stored record, anddescribeExpect, which isdescribeSchema. A site declaring no schema defers with no contract, validates nothing at the resume ingress, and typesex.deferral.resultasunknown.- The acknowledgment dropped
questionandreason. Carry them in.defer({ meta }), which is persisted verbatim and handed to.resume({ authorize }), or in the notification step that already runs before the deferral. - The deferral store starts again at schema version 1, as
.routecraft/deferrals.dbwith adeferralstable and the application idRCDE. On defaults that is a different file from the canary's.routecraft/suspensions.db, so the old one is abandoned rather than read: nothing fails, and deleting it reclaims the disk. A configured path carried over unchanged IS opened, and refused at boot as not a deferral store; delete that database or name a new path. - The record carries two states, not five.
statusisstate: "waiting" | "settled", pluswaitingFor("resume"today) for what the work is waiting for andoutcome: { kind, at, reason?, by? }for how it settled, wherekindis"resumed" | "expired" | "denied". The oldexpiringstatus was a delivery claim rather than an outcome and is nowclaimedAton a record that is stillwaiting: a deferral is resumable while it is waiting and unclaimed, which the exportedresumable(record)predicate answers.settledAt,resumedAt,resumedByanddeniedReasonfold intooutcome. - The cached result of execution two is
continuation, notterminal. On the record and on the resume acknowledgment alike, where it was also calledoutcome. One word per concept: an outcome is how the deferral ended, a continuation is how the resumed run ended. - A custom store gained members during the canary:
findExpired(now, limit, after?),claimExpiry(id, at),releaseClaims(before),resumedWithoutContinuation(limit?),recordContinuation(id, result), andreplaceStepState(id, fingerprint, next).releaseExpiringandresumedWithoutTerminalare the old names of the middle two. - A resumed continuation honours the route's filter chain. A route declaring route-scope
.retry()alongside.defer()gains at-least-once execution of the steps after the deferral, with no opt-in, so make those steps idempotent.
12. What is new in 0.7.0
For context, no migration required:
- Durable defer and resume.
.defer(),.resume(), the deferral store with signed tokens, the sweeper,.resume({ authorize }),meta,tokenFor, and re-entrant defer sites for adapters that defer mid-step. - Durable agents and sessions.
ctx.defer()inside a tool defers the loop;agent(name, { session })keeps a transcript with an inbox, one turn at a time,interrupt: true, anddirectTool(id, { background: true })for work that outlasts a turn. See durable agents and the agent adapter. - Talk to an agent from your editor.
acp:ondefineConfig,craft acp, profiles, the model and thinking-level pickers, and thesurface()adapter for a capability that reaches the editor. See talk from your editor. - The ops surface. Health, readiness, indicators, the management API, contributed resources and the event tail, behind scope-gated tiers. See the ops plugin.
- Remotes. Another instance's dispatchable routes as direct endpoints. See the remotes plugin.
- The project runtime.
craft startboots a project from its layout, Claude Code agent files load unchanged,registerProjectDiscovererlets a package claim a folder. See project structure and the CLI reference. .enabled(),authorize({ anyScope, effective }),direct({ internal: true }), RFC 9728 discovery on every http surface,timingSafeStringEqual,isStandardSchema,validateAgainst,isDropped,parseDuration,anySignal.- The
http()source: Server-Sent Events and streaming bodies,respondfor early acknowledgment, the Standard Webhooks signature scheme,maxStreamingRequestsandidleTimeout. Thehttp()client:redirect,responseBody: "bytes". shell()in@routecraft/os, isolated by default, with thedockertier; therequire-untrusted-shell-argslint rule.- Model controls:
reasoning,providerOptions, the sampling block onagent(), content parts in the user prompt,agent({ stream: true }), typed prompt callbacks. - MCP: default icons with a PNG per theme,
plugin:mcp:tool:declinedandplugin:mcp:tool:deferred. - New error codes:
RC1004,RC5040toRC5064,AI1004toAI1019,AI2001andAI2002,OS1001andOS1004. See the errors reference.