surface
import { surface, hasSurface } from '@routecraft/ai'
Reach the person whose turn a route is running on. When somebody talks to an agent from their editor, the editor is a program that can read a file, run a command, show a plan or ask a question, and surface is the one seam a capability uses to ask it.
Two roles, because a route does two different things with a surface:
surface(method, params)is an enricher. It asks, and the turn waits for the answer, which is what an approval is.surface.notify(update)is a destination. It tells, and nothing waits.
import { craft, direct } from '@routecraft/routecraft'
import { surface } from '@routecraft/ai'
craft()
.id('read-file')
.from<{ path: string }>(direct())
.enrich(
surface('fs/read_text_file', (ex) => ({ path: ex.body.path })),
)
The protocol's sessionId is not part of the params. The adapter fills it in from the running turn, which is the only conversation a route can mean: a route that could name another would be addressing another person's editor. A callback that supplies one does not compile.
What a method may be
The method names and their shapes come from the Agent Client Protocol's own generated types rather than being restated here, so a method the protocol adds is callable the day the SDK ships it and one it never had does not typecheck. Today that is the client half of the protocol: reading and writing files, terminals, and elicitation.
The framework provides the seam and ships none of the capabilities that use it. Reading a file through the editor, running something there, showing a plan: each is an ordinary route somebody else owns, with its own guardrails written where a reader of that route can see and change them. That is the same line the framework draws for tool calls, and it is why there is one adapter here and no readFile() beside it.
Guarding the call
import { craft, direct } from '@routecraft/routecraft'
import { hasSurface, surface } from '@routecraft/ai'
craft()
.id('confirm-or-proceed')
.from(direct())
.choice()
.when((ex) => hasSurface(ex))
.enrich(surface('session/request_permission', () => ({ options: [] })))
.otherwise()
.end()
hasSurface(exchange) is the guard a route branches on so the same route works from an editor and from a schedule. Asking without it is not a silent no-op: it throws.
What the editor answers is checked
The editor is outside the trust boundary: a separate process on the person's machine, any program claiming to be one, at whatever version of the protocol it implements. Every other external input to a route is parsed before use, and what an editor answers is no exception. surface() checks every answer against the protocol's own schema for the method, the generated JSON Schema the SDK ships, before the route sees it. Nothing malformed reaches route code as if it were valid, and there is no option to turn the check off.
What a malformed answer does depends on the method, because the two cases have different safe directions:
session/request_permissionfails closed. An answer that does not parse, or aselectedoutcome naming an option that was never offered, reaches the route as the protocol's own{ outcome: { outcome: 'cancelled' } }, exactly as if the person had dismissed the prompt. A route that checks forselectedwith an allowing option sees a refusal either way, and a route cannot be talked into proceeding by an answer it never asked for. The instance logs a warning naming the issues, since a client answering this way is worth knowing about.- Every other method is an error the route can catch. A file read, a terminal call or an elicitation answered with the wrong shape is
AI1018, with the issues on the error's cause. Handle it with.error()as you would a refusal; the editor cannot answer this call correctly, so there is nothing to retry.
surface.notify() is under the same rule in the other direction: the update a route builds is checked against the protocol's session/update shapes before it is sent, and one that does not conform is AI1019 and never leaves the instance.
The protocol reserves _meta on every message and forbids assumptions about its values, so that field is never checked; and a field the SDK itself defaults when it fails to parse is accepted whatever it holds. Both are the SDK's own liberties, mirrored so an answer the SDK would accept is not refused here.
The checks are built from the schema the installed SDK ships, so a method the protocol adds is checked the day its SDK does. A check that cannot be built, because the installed schema has no response for the method or the converter refuses its definition, is RC5003 before the editor is asked: a mismatch between @routecraft/ai and the SDK version, never reported as the editor's answer, and never a permission call falling closed for it. Align the two versions.
How it fails
Five ways, each with a different fix, which is why each has its own code:
AI1015 is checked before the call rather than discovered from its failure. A client that never offered a method may answer anything at all, or nothing, and a route deserves to be told it is a configuration mismatch rather than left waiting.
When the editor goes away
A call waits for a person, and the connection carrying it can die while it waits. What happens then is a contract rather than whatever the code did:
- The connection drops while a call is outstanding. The call settles at once with
AI1014, and the exchange fails through the route's own error path. Nothing waits for the editor to come back: a reconnected editor is a new surface, the old call is never re-sent, and any answer the person gave on the dead connection is lost with it. A route that wants the answer asks again on a new turn. - The instance restarts while a call is outstanding. The call dies with the process, and the turn is closed as interrupted the next time the conversation is touched. A turn revived from its stored continuation carries the surface reference it had, naming a connection that no longer exists, so a call from it is
AI1014too;hasSurfaceanswers false for it. Nothing outstanding at the restart is re-sent, because the person may already have answered it. - In both cases the exchange settles. Nothing is left waiting on an editor that cannot answer, and a stored continuation is either revived and failed or released, never left referenced by nothing.
AI1014 in both cases rather than a refusal, because the fixes differ: a person saying no is an outcome the route handles, and a socket going away is not.
When the turn is cancelled
A route the turn called keeps running after the person presses stop. The agent unwinds at once; the route finishes under its own lifecycle, because a route abandoned mid-step is worse than one that completes. What changes is the surface: the call the route has outstanding is cancelled at the editor and settles with AI1016, and a call the route makes after that is refused with AI1016 without being sent. Nothing new reaches a person who said stop.
That leaves the cleanup a route owes. A terminal it created is still running in the person's editor, and a terminal/release in a finally is exactly the call that is now refused. So the cleanup is declared before the cancel, and the framework sends it after:
import { craft, direct } from '@routecraft/routecraft'
import { surface } from '@routecraft/ai'
craft()
.id('run-command')
.from<{ command: string }>(direct())
.transform(async (body, ex) => {
const created = await surface('terminal/create', {
command: body.command,
}).fetch(ex)
const terminalId = created.terminalId
const release = surface.onCancel(ex, [
{ method: 'terminal/kill', params: { terminalId } },
{ method: 'terminal/release', params: { terminalId } },
])
try {
const exit = await surface('terminal/wait_for_exit', {
terminalId,
}).fetch(ex)
// Only the path that got here withdraws: it is about to do the
// release itself.
release()
await surface('terminal/release', { terminalId }).fetch(ex)
return exit
} catch (failure) {
// The wait failed, and the registration is still standing. Try the
// cleanup anyway and swallow what comes back: if the turn was
// cancelled these are refused and the framework sends the registered
// pair instead, and if it was not, this is the only thing that will
// close the terminal. A route cannot tell the two apart from the
// error, since a person refusing is AI1016 as well.
try {
await surface('terminal/kill', { terminalId }).fetch(ex)
} catch {
// Nothing to do with it.
}
try {
await surface('terminal/release', { terminalId }).fetch(ex)
} catch {
// Nor with this one.
}
throw failure
}
})
surface.onCancel(exchange, requests) registers calls to make if the turn is cancelled while this exchange is still running, and returns the call that withdraws them. The framework sends them in the order given, each under a short deadline, after session/prompt has answered cancelled, and logs rather than throws when one fails. Only what was registered runs: there is no grace period in which a route gets a fresh surface after the person said stop, which is the whole reason this is a registration.
Three things about the registration:
- It is checked when made. Every method is checked against what the client advertised, as a call would be, so a route learns of a mismatch while it can still act on it.
- It dies with the exchange. A route that finished its own cleanup withdraws the registration, and one that finished without saying so has it dropped when the exchange completes, so a cancel later in the conversation cannot replay a release that route withdrew. A route that cleans up without withdrawing, which is what the example's failure path does deliberately, can have the registration sent as well: the second release is refused or logged, and that is the cheaper side of the trade against a terminal nobody closes.
- It is sent after the cancelled turn has finished. A cancel is raised while the turn is still unwinding, so the cleanup waits for that turn's own exchange to settle before anything goes out: the editor learns the turn ended before it is asked to close what the turn left open. The wait is bounded, because a turn whose exchange never reports a terminal event must not strand a terminal in somebody's editor forever.
- It runs on cancel, not on every ending.
session/canceland an interrupting message trigger it. A turn that ends any other way leaves the route to its ownfinally, which still works, because the surface is only refused after a cancel. That includes a graceful stop: the drain lets in-flight exchanges finish and a route releases what it holds the ordinary way. What a forced shutdown abandons it abandons here too, on the terms core already states: an exchange cut at the deadline emits no terminal event, and its registered cleanup is not sent. - It is the framework making calls rather than carrying them, which is the one place the seam does more than transport. It is bounded to that: a fixed list, declared in advance by the route, of methods the client already advertised, sent once, in order, under one deadline. Nothing is added to the seam to make it convenient, and a typed wrapper over a terminal or a file still belongs above it, in a route somebody owns.
How far it reaches
A capability the turn calls is not the same exchange as the turn. surface resolves the connection three ways so all of them work: the surface travels on the turn's own headers, a route the turn dispatches to carries the correlation id instead of the whole header bag, and an exchange that has resolved its surface once keeps it for as long as it runs. That is what lets a capability three hops from the prompt still reach the person who typed it, and what lets a route still running when the turn ends see the surface it had rather than none.
Backends
acp is the only surface kind today, registered by acpPlugin while an editor is connected. Nothing in the adapter knows how the connection is transported, which is what lets a second backend arrive without the routes changing.
One thing a backend must do explicitly: reject with SurfaceDisconnected when the transport went away while a call was outstanding, so the adapter reports AI1014 rather than AI1016. Anything else it rejects with is read as the person refusing. The two have different fixes and only the backend can tell them apart, because only it knows whether the peer answered or the socket died.