FFI sidecars
Implement external agents in TypeScript with @katari-lang/port, move files across the boundary, and ship the sidecar in a package.
When an integration needs a real client library — a gateway socket, a sandbox SDK — you declare
an external agent in Katari and implement it in a TypeScript sidecar: a file with the same
basename, bundled by katari apply and run by the runtime as a long-lived subprocess. The
typed boundary stays in Katari; the TypeScript is as small as the client library demands.
Declare and implement
The Katari half — in a project named sensors, so src/sensors.ktr is the package's own
module — an external agent has a signature but no body:
@"Read one sensor's current value; implemented in `sensors.ts` beside this file."
external agent read_sensor(id: string) -> number
agent main(id: string) -> string {
f"sensor ${id} reads ${string.to_string(value = read_sensor(id = id))}"
}The TypeScript half, in src/sensors.ts — handlers register with
@katari-lang/port under the file's module path, so katari.agent("read_sensor", ...) implements exactly the sensors.read_sensor the compiler lowered:
import { katari } from "@katari-lang/port";
katari.agent<{ id: string }>("read_sensor", async ({ id }) => {
const response = await fetch(`https://sensors.internal/api/${id}`);
const body = (await response.json()) as { millivolts: number };
return body.millivolts;
});The handler's argument is the agent's declared input record; the return value is encoded against
the declared output. The call site was already type-checked in Katari, so the sidecar assumes its
argument shape — the boundary contract lives in one place, the .ktr signature.
Move files across the boundary
file values cross in both directions; the bytes ride a blob side channel, never the argument
JSON:
@"Render recent readings as a PNG; the bytes ride the blob side channel back as a `file`."
external agent render_chart(series: array[number]) -> file
@"The sidecar downloads the file's bytes and measures them."
external agent byte_length(content: file) -> integerimport { katari, type KatariFile } from "@katari-lang/port";
katari.agent<{ series: number[] }>("render_chart", async ({ series }, context) =>
context.file(renderPng(series), { contentType: "image/png" }),
);
katari.agent<{ content: KatariFile }>(
"byte_length",
async ({ content }) => (await content.bytes()).length,
);context.file uploads bytes (or UTF-8 text) and returns a KatariFile you can return or pass
onward; an incoming file argument arrives as a KatariFile whose bytes() / contentType()
download on demand. The discord package uses both directions at once: incoming message
attachments download from Discord's CDN and lift into file values, and outgoing file values
attach to posts.
Raise a typed error
Declare the throw on the external agent's row, and raise it with katari.throw carrying a
KatariData value — the Katari side catches it like any typed error:
@"The typed error `parse_reading` raises for malformed text."
data bad_reading(message: string)
@"Parse a reading; the sidecar raises `throw[bad_reading]` on malformed text."
external agent parse_reading(text: string) -> number with prelude.throw[bad_reading]
@"Fall back to zero on a malformed reading."
agent parse_or_zero(text: string) -> number {
use handler {
request prelude.throw(error: bad_reading) -> never { break 0 }
}
parse_reading(text = text)
}import { katari, KatariData } from "@katari-lang/port";
katari.agent<{ text: string }>("parse_reading", ({ text }) => {
const value = Number.parseFloat(text);
if (Number.isNaN(value)) {
katari.throw(new KatariData("sensors.bad_reading", { message: `not a number: ${text}` }));
}
return value;
});An ordinary exception (a rejected promise, a throw new Error(...)) is not typed: it fails the
call as a panic, which escalates like any defect. Reserve katari.throw for the failures the
signature anticipates, and let genuine bugs panic.
Call back into Katari
A handler can delegate back through the runtime mid-call — so the sidecar stays a thin adapter and the logic stays typed:
@"Convert a raw reading; the sidecar calls this agent back through the runtime."
agent celsius_of(millivolts: number) -> number {
(millivolts - 500) / 10
}
@"Read a sensor and convert it, with the conversion running in Katari."
external agent read_celsius(id: string) -> numberkatari.agent<{ id: string }>("read_celsius", async ({ id }, context) => {
const millivolts = await context.call<number>("sensors.read_sensor", { id }, { reactor: "ffi" });
return context.call<number>("sensors.celsius_of", { millivolts });
});context.call reaches a Katari agent by qualified name (the default), or another FFI handler by
key with reactor: "ffi". A callee that runs and then fails does not reject the promise — its
failure unwinds up the delegation to Katari-side handlers, so you catch a callee's error in
Katari, not in TypeScript.
Serve a long-lived stream
An external agent can take an agent argument and deliver into it repeatedly — the shape of
discord.watch_messages, and the FFI twin of time.watch:
@"Deliver each upstream event into Katari as it arrives; the sidecar owns the socket. Shaped like
`time.watch`: it never resolves on its own, and @deliver_to@'s effects flow to the caller's handlers."
external agent watch_events[effect E](
channel: string,
deliver_to: agent (payload: string) -> null with E,
) -> never with E
@"React to one event."
agent on_event(payload: string) -> null with io | prelude.throw[http.fetch_error] {
let _response = http.fetch(
url = "https://api.example.com/ingest",
method = "POST",
headers = record.empty(),
body = http.text(content = payload),
)
null
}
agent main() -> never with io | prelude.throw[http.fetch_error] {
watch_events(channel = "alerts", deliver_to = on_event)
}import { katari, type KatariAgent } from "@katari-lang/port";
katari.agent<{ channel: string; deliver_to: KatariAgent }>(
"watch_events",
({ channel, deliver_to }, context) =>
new Promise<never>((_resolve, reject) => {
const socket = connectTo(channel);
socket.onEvent((payload: string) => {
deliver_to.call({ payload }).catch((error: unknown) => {
socket.close();
reject(error instanceof Error ? error : new Error(String(error)));
});
});
// The runtime cancelled the call (run cancel / teardown): stop listening and settle.
context.signal.addEventListener("abort", () => {
socket.close();
reject(new Error("watch cancelled"));
});
}),
);The handler's promise never resolves; each event becomes an inner delegation whose effects flow
to the caller's handlers. Observe context.signal so a cancelled run tears the socket down. A
delivery failure rejects the promise and the watch dies — resilience composes around it with a
replay provider, exactly as for
scheduled jobs.
Ship it in a package
A sidecar package is a normal Katari package whose source directory holds both halves — e2b
(one file each way) and discord (a gateway client) are the working references:
katari-package-e2b/
katari.toml # [package] name = "e2b" — no [sidecar] section needed
package.json # depends on @katari-lang/port + the client SDK
src/
e2b.ktr # the typed surface: externals, provider, tools
e2b.ts # the sidecar: katari.agent(...) handlers
{
"name": "katari-package-e2b",
"private": true,
"type": "module",
"dependencies": {
"@e2b/code-interpreter": "^1.5.0",
"@katari-lang/port": "0.1.0-rc7"
}
}The sidecar's source root defaults to [package].src, so .ts files simply live beside the
.ktr files; a package with a different layout sets [sidecar] sourceRoots in its
katari.toml. On katari apply, the CLI bundles every package's sidecar sources (via the
katari-bundle helper from @katari-lang/bundle) into the snapshot it deploys — consumers of
your package never run a build step.
Three consequences of the process model worth designing for:
- One long-lived process per snapshot. The runtime spawns the sidecar lazily on the first
call and keeps it alive, so module-level state (the
discordpackage's client map,e2b's sandbox handles) persists across calls — for the life of the process, not durably. Its stderr is passed through as the sidecar's log. - Crashes are contained. A crashed sidecar is respawned; the calls in flight fail as panics rather than hanging forever.
- Execution is at-most-once. The runtime never re-runs a handler: a call in flight across a
runtime restart fails with an "interrupted" panic instead of silently running twice. Whether to
retry is a Katari-level decision — a panic converter plus a
replayprovider, composed by the caller.
Survive a runtime restart
Here is that composition — a panic converter plus a replay provider — for a long-lived bot. A
sidecar's client handle is process-local (the slack / discord client map lives in the subprocess,
not the durable log), so a runtime restart resumes the run holding a handle to a connection that no
longer exists, and its next watch / send fails as an interrupted panic. The converter turns that
panic into a replay.interrupted signal and replay.forever re-runs the block — and because
slack.provider sits inside the replay scope, each replay reconnects with a fresh handle:
import slack
@"Reply to one message. A transient `api_error` is caught so one failed send never ends the bot; an
`auth_error` (a bad token) surfaces and stops it loudly — the per-message channel, a typed throw."
agent reply(channel: string, user: string, text: string, thread_ts: string | null, files: array[file]) -> null with io | slack.get_slack_client | prelude.throw[slack.auth_error] {
use handler {
request prelude.throw(error: slack.slack_error) -> never {
match (error) {
case slack.api_error(_) -> { break null }
case slack.auth_error(message => message) -> { prelude.throw(error = slack.auth_error(message = message)) }
}
}
}
slack.send_message(channel = channel, text = f"you said: ${text}", files = [], thread_ts = thread_ts)
}
@"The restart-resilient bot: the panic converter re-runs connect-and-serve after a capped backoff, and
`slack.provider` — inside the replay scope — reconnects each time with a fresh Socket Mode client."
agent main(channel: string) -> never with io | prelude.throw[slack.slack_error | env.missing_secret] {
use replay.forever(initial_delay_milliseconds = 1000, factor = 2, max_delay_milliseconds = 60000)
use handler {
request panic(msg: string) -> never {
replay.interrupted(failure = msg)
}
}
use slack.provider(
bot_token = env.get_secret(key = "SLACK_BOT_TOKEN"),
app_token = env.get_secret(key = "SLACK_APP_TOKEN"),
)
slack.watch_messages(channel = channel, deliver_to = reply)
}The two failure channels stay separate: a per-message send failure is a typed slack_error caught in
reply, while the restart failure is a panic caught by the converter. replay.forever sleeps its
capped backoff durably and re-runs the block, so a fresh create_slack_client mints a live handle
where the journal replayed a dead one — the same replay composition
scheduled jobs use for a time.watch, catching a
panic instead of a throw.
Where to go next
- Packages — publishing to the registry, and what consumers see.
- Durable execution — where the FFI boundary sits in the recovery story.
- The
@katari-lang/portAPI in the reference.