remotesPlugin

import { remotesPlugin } from '@routecraft/routecraft'

Names other running instances, and every dispatchable route they expose through the ops management API becomes a direct endpoint here. direct(), forward(), directTool(), Direct(...) in an agent's tool list, the tool policy, the local /ops/routes listing, craft ops routes and craft exec all see them as routes, with nothing to learn.

The shape it exists for: one constrained instance hosts approved capabilities behind its own door, with its own service credentials and its own .authorize(), and every engineer runs a local instance with their own agents and their own routes. The local instance names the server, calls its routes as if they were local, and promotes a route to the server when it proves useful. The ops API is plain HTTPS and JSON, which is what makes it reachable where richer protocols are not.

remotes is a first-class core config key, so the common path is defineConfig({ remotes: {...} }) rather than plugins: [remotesPlugin(...)]. The factory is exported for programmatic composition.

import { defineConfig } from '@routecraft/routecraft'

export default defineConfig({
  remotes: {
    default: {
      url: 'https://routecraft.example.internal',
      auth: { token: () => process.env.RC_REMOTE_TOKEN },
    },
    lab: {
      url: 'https://lab.example.internal',
      auth: { token: () => process.env.RC_LAB_TOKEN },
    },
  },
})
craft()
  .id('greet-twice')
  .from(direct())
  .enrich(direct('hello'))      // the default remote's route, advertised bare
  .enrich(direct('lab:hello'))  // any other remote is qualified

agent({
  tools: tools(['Direct(hello)', 'Direct(lab:hello)', 'Remote(lab)']),
})

Options

Each entry under remotes is one remote, keyed by name:

OptionTypeDefaultDescription
urlstringrequiredThe instance's base URL, the origin its ops surface is mounted on
auth.tokenstring | () => string | undefined | Promise<...>noneBearer for the remote's door. A function is evaluated per request, so a rotated token is picked up without a restart
timeoutDuration'30s'Per-request deadline against the remote
refreshDuration | false'60s'How often the inventory is re-read. false leaves only the boot read and the refresh a 404 forces

A remote's name becomes one segment of the tool name a model sees (remote__lab__hello) and the prefix of every qualified endpoint (lab:hello), so it must match /^[A-Za-z0-9][A-Za-z0-9_-]*$/, must not contain __ and must not end in _, the same rule an MCP client name follows for the same reason. A bearer over plain http: is refused at configuration unless the address is loopback, the rule craft already enforces: every hop between here and the remote can read it.

Names

Git-style. The routes of the remote called default are advertised bare, exactly as a local route would be, so direct('hello') and Direct(hello) reach them with nothing to learn. Every other remote's routes are qualified name:id, and default:id names the default remote explicitly. Two remotes therefore never collide.

Local wins. A local route with the same id as a default-remote route shadows it: the local route answers direct('hello'), the remote route stays reachable as default:hello, and the shadow is logged as a warning on every refresh that finds it and on every call that resolves to the local route while it exists. That overlap is the promotion window, the moment a capability exists on a laptop and on the server at once, and it is never an error. When the local route stops, the remote route takes the endpoint back: the listing and the tool policy see it as the remote's from that moment, and the handover is logged. A local route whose id is itself a qualified name (lab:hello) shadows the remote route entirely until one of them is renamed, and the warning says so.

A route of default whose own id starts with another remote's name and a colon (lab:x on the default remote, beside a remote called lab) would be advertised under an endpoint that remote already holds. It is skipped with an error in the log, and stays reachable as default:lab:x.

Credentials and identity

The credential is the environment's. auth.token is read from wherever the deployment keeps it, as a string or as a function evaluated per request. The CLI settings file (.routecraft/settings.yaml) is for the local instance the CLI talks to, and core never reads it. The identity the remote sees is whatever its own validator mints from the token, so this plugin makes no call on api keys versus JWTs, and its docs describe none: the remote's ops surface decides.

The local caller's principal is never forwarded. A route imported from a remote runs there under the identity the bearer carries, whoever dispatched it here, which is the same two-boundary rule an MCP client follows: the principal authenticates the caller into this instance, and the remote's credential authenticates this instance into the remote.

Inventory and schemas

The inventory is the framework's. At start, GET /ops/routes?dispatchable=true and GET /ops/routes/{id} for each route are read from every remote, awaited, so an imported route is a local endpoint by the time the context reports ready; then on the refresh interval; and forced by a dispatch that meets a 404 for a route the inventory still lists. A remote unreachable at boot registers nothing, logs a warning, and reports its remote.<name> indicator down on the local health endpoint; its routes appear on the refresh that first reaches it, and a remote that blinks keeps the last inventory rather than taking every endpoint with it.

Each route's input and output arrive as the JSON Schema the remote rendered and become the endpoint's discovery bundle, so an agent tool advertises the remote's input schema to the model exactly as it would a local route's, and validation runs where the schema lives: at the remote's door.

Outcomes

A dispatch through an imported endpoint maps the remote's outcome onto the in-process one:

The remoteHere
completesthe body
dropsRC5031, as for a local drop
defers on a .defer()the standard Deferred acknowledgment; resume is at the remote's door, under its policy
fails (500 with a code)RC5064, carrying the remote's code as cause
refuses the credential (401 or 403)RC5063, naming the door's own reason, so a credential problem is distinguishable from a broken route
cannot be reached, loses the connection, or does not answer in timeRC5062; a connection that never opened is retryable, a lost connection or a timeout is not
answers 404the inventory is refreshed, then RC5004: the route is gone from the remote or its dispatch tier is closed

On a runtime that needs it, a dispatch is sent on a connection of its own, and the inventory and health reads beside it keep the pool. The reason is that a dispatch must never run twice: below Bun 1.3.14 the runtime's fetch re-sent a request on a fresh connection when a reused keep-alive socket closed before any response byte arrived, which ran the remote route a second time beneath the layer that classifies the failure, so the caller was told once about work that happened twice. Taking the connection out of the pool leaves nothing to re-send it on. The cost is one handshake per dispatch, and it is paid only on a runtime that behaves that way: Bun 1.3.14 and later, and Node on every version, dispatch through the pool like every other call.

In the listing and the tool policy

An imported route shows in the local /ops/routes as dispatchable with sources: ["remote"] and a remote field naming its origin; ?source=remote lists exactly the imported ones. The agent tool policy's direct rule sees source.remote ('default', 'lab', or absent for a local route), so a policy can keep an agent local-only:

import { agentPlugin } from '@routecraft/ai'

agentPlugin({
  toolPolicy: {
    fn: true,
    direct: (tool) => tool.source.remote === undefined,
    mcp: false,
  },
})

Re-exposure is intentional. A local instance with an open dispatch tier re-exports every imported route under its own door, exactly as a tool fronting an authenticated REST call re-exports that call; gate the local tiers the way you would gate that tool.