# surface

[← All adapters](/docs/reference/adapters)

```ts
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](/docs/advanced/talk-from-your-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.

```ts
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

```ts
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_permission` fails closed.** An answer that does not parse, or a `selected` outcome 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 for `selected` with 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`](/docs/reference/errors#ai-1018), 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`](/docs/reference/errors#ai-1019) 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`](/docs/reference/errors#rc-5003) 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:

| Code | What happened | What to do |
| --- | --- | --- |
| [`AI1013`](/docs/reference/errors#ai-1013) | The route ran outside a surfaced turn | Guard with `hasSurface`, or take another path |
| [`AI1014`](/docs/reference/errors#ai-1014) | The surface disconnected, before the call or while it was outstanding | Nothing to retry against on this exchange |
| [`AI1015`](/docs/reference/errors#ai-1015) | The client never advertised the capability | Configuration, on the editor's side |
| [`AI1016`](/docs/reference/errors#ai-1016) | The client refused or failed the call, or the turn was cancelled | Handle it: a person saying no arrives this way, and is a normal outcome |
| [`AI1018`](/docs/reference/errors#ai-1018) | The client answered with something that is not the protocol's shape | Handle it: the editor cannot answer this call correctly. A permission answer becomes `cancelled` instead |

`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 `AI1014` too; `hasSurface` answers 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:

```ts
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/cancel` and an interrupting message trigger it. A turn that ends any other way leaves the route to its own `finally`, 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](/docs/reference/configuration#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`](/docs/reference/plugins/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`](/docs/reference/errors#ai-1014) rather than [`AI1016`](/docs/reference/errors#ai-1016). 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.
