Skip to content
Markdown

Kernel-backed agent runtimes

Scope: the harness pattern in which an agent session owns a persistent interpreter process and that interpreter is the agent's single model-facing tool. Reading files, running project commands, calling libraries, invoking skills, and delegating work are all expressed as code in one durable namespace rather than as separate tool schemas. This page covers the programming model, the typed host bridge that keeps credentials and persistence outside the interpreter, the wire-level rules a host dispatcher must get right, and the trust boundary the design does not provide. The generic anatomy of this layer is agent harness architecture; the schema-based interface it replaces is tools and function calling; the in-process variant that binds the agent to Python objects instead of a separate kernel process is object-oriented agents; the containment it deliberately does not supply is agent sandboxing and isolation.

What is and is not reproduced here. No model was run and no production harness was installed: this box has no GPU and no provider key, so no latency, token, or benchmark figure on this page is measured here. The two Python blocks are executed on system python3 3.12.3 with the standard library only, and the pasted output is byte-identical to the run. They validate the two mechanisms that hold without a model: channel-ordering behaviour between a host and a single-threaded interpreter loop, and host-side frame dispatch. Both are written as small honest models of the message semantics, not as a running Jupyter kernel, and they are labelled as such where they appear. Architectural facts are read from the published design documents of a kernel-backed harness at commit a18809e (2026-08-07), cited under References.

What it is

A conventional harness grows one tool schema per capability. Read a file, write a file, list a directory, run a command, search the web, query a database: each is a JSON schema the model sees on every turn, each is an independent round trip, and each result is serialised back into the transcript.

A kernel-backed runtime collapses that catalog into one tool. The harness starts a long-lived interpreter process bound to the session and exposes exactly one action to the model: execute code in it. Everything else is a library call inside that process.

Two properties separate this from simply shipping a bash tool.

The namespace is durable. Variables, imports, helper functions, parsed results, and handles created on turn 3 are still there on turn 40. They also survive context compaction, because compaction rewrites the transcript and the transcript is not where the working state lives. An agent that summarises away its own narrative keeps its parsed dataframe.

Privileged operations do not live in the interpreter. Anything whose authoritative state belongs to the harness is reached through a typed request that the interpreter cannot satisfy itself. The interpreter asks; the host validates, performs the state transition, and answers.

Concern Where it lives
File inspection, data transforms, project commands, library calls model-facing interpreter
Skill implementations exposed as importable callables model-facing interpreter
Provider credentials and model invocation host
Transcript and artifact writes host
Child session lifecycle and depth policy host
Scheduling, goals, and continuation policy host
Usage and cost attribution host

The split is the point. The model gets a real programming surface; the harness keeps everything that must be audited, billed, or trusted.

A skill in this design is not a tool schema. It is a package the harness installs into the interpreter environment plus a short instruction document. Only the skill metadata sits in the startup prompt; the full instructions load when the task matches, and the model then calls a typed function:

# Illustrative shape, not a specific product API. The point is that a capability
# arrives as an importable callable with a signature, not as a JSON schema that
# occupies prompt budget on every turn.
report = await release_audit(repository=".", target_version="0.4.0")
failures = [item for item in report.checks if not item.passed]

Because the capability is a callable, the model can loop over it, filter its output, retry it, and combine it with three other callables inside a single execution, paying one model turn instead of eight.

Why use it

Tool surface stops scaling with capability count. Adding the fortieth capability adds an import, not a schema in every request. This matters most where the schema catalog has become the dominant fixed cost of a turn; see the harness effect on token economics.

Working state stops round-tripping through the transcript. A parsed 200k-row table is a variable, not 200k tokens of serialised tool output followed by 200k tokens of it being quoted back. The data the agent can process is bounded by the machine, not by the context window.

Compaction stops being destructive to work in progress. This is the sharpest operational consequence. In a schema-based harness the transcript is the only memory, so compaction is a lossy trade against the agent's ability to continue. With a durable namespace, compaction removes narrative and keeps state, so long tasks tolerate it. It also means compaction is not a completion signal: goals, schedules, and running children are unaffected by it. The trigger policy itself is covered in when to compact.

Composition becomes free. Chaining, filtering, and error handling across capabilities are ordinary control flow evaluated locally, not a sequence of model turns each costing a full prefill.

Integrations can be adopted without prompt cost. An external protocol server can be wrapped as a Python client inside the interpreter, so its operations become callable without adding anything to the model's visible tool surface. That decouples "how many integrations do we have" from "how big is every request".

When to use it (and when not)

Use it when the work is long, stateful, and data-heavy: multi-hour repository work, data exploration where the intermediate object is large, or any task expected to survive a client disconnect and resume later.

Do not use it when the action space must be enumerable and individually gated. A policy engine can require approval for delete_file(path="/etc/passwd") because the action is a typed, inspectable request. It cannot meaningfully gate arbitrary interpreter input, because deciding what a program will do is undecidable in general and trivially obfuscated in practice. If your control model depends on per-action approval, keep the schema catalog; see policy engine and risk-tiered approval gates.

Do not use it for short single-shot tasks. A durable interpreter costs a process, a managed environment, and a bootstrap on first use, and none of that pays back inside one turn.

The interpreter process is not a security boundary. It is a lifecycle and failure-containment boundary. It normally runs with the same operating-system permissions as the harness and the user who started it, so model-generated code can do whatever that user can do. Installed packages, skills, and extensions are trusted code. Untrusted repositories, untrusted instructions, or untrusted third-party skills require an actual sandbox around the whole worker, not the kernel boundary; see agent sandboxing and isolation and prompt-injection defense.

Architecture

flowchart TD
    model["Model provider stream"]
    session["Session host<br/>policy, credentials, persistence"]
    manager["Kernel manager<br/>execution and channel dispatch"]
    kernel["Interpreter process<br/>one durable namespace"]
    skills["Installed skill packages"]
    code["Model-authored code"]
    store["Transcript and session artifacts"]

    session <-->|"prompts and tool calls"| model
    session -->|"owns"| manager
    manager <-->|"shell: execute<br/>iopub: output<br/>control: host replies"| kernel
    kernel --> skills --> code
    code -->|"typed host request"| manager
    manager -->|"validated dispatch"| session
    session --> store

Three channels carry different traffic and the separation is load bearing, not cosmetic. The request channel carries execution requests and their replies. The output channel carries stdout, stderr, results, errors, and status. The control channel carries interrupts, shutdown, and, critically, replies to host requests raised by a cell that is still running.

How to use it

The turn shape is: the model emits code, the host executes it in the session's interpreter, and the output returns as the tool result. State accumulates.

# Turn 3: build working state.
from pathlib import Path

configs = list(Path(".").rglob("*.toml"))
oversized = [p for p in configs if p.stat().st_size > 10_000]
# Turn 11, after two compactions: still there.
len(oversized)

Shell access is usually a cell magic that runs in a temporary subshell, which produces an asymmetry worth internalising: environment changes made by a shell cell do not persist, while interpreter state and the interpreter's own working directory do. An agent that runs export in a shell cell and expects it on the next turn is wrong; an agent that assigns a Python variable is right.

Delegation, goals, messaging, and compaction requests are all typed host requests rather than local functions. They look like ordinary awaits in the interpreter, but the state they change belongs to the host:

# Illustrative shape. The call returns after the host admits the task; the
# child's answer is not the return value. See the delegation page.
handle = await spawn("Review the authentication flow", name="auth-reviewer")

How to develop with it

The first thing to get right when building this layer is which channel a host reply travels on, because getting it wrong produces a hang that looks like a slow model.

An interpreter that processes execution requests in a single loop runs one cell to completion before it reads the next request frame. If a running cell awaits a host reply, and the host answers on the request channel, the reply lands in a buffer that nobody will read until the cell finishes, and the cell will not finish until the reply arrives. The two processes below make that concrete with real sockets.

"""kernel_channels.py -- why a host reply to an executing cell cannot ride the
request channel. Two real OS processes, two real socketpairs, one single-threaded
request loop on the kernel side. Standard library only.

The kernel dispatches requests from the `shell` socket in a single reader loop:
it runs one cell to completion before reading the next shell frame. A cell that
awaits a host reply therefore cannot be woken by anything the shell loop would
have delivered. The control socket has its own reader thread, so it can.
"""

import json
import multiprocessing as mp
import socket
import threading

TIMEOUT = 3.0  # a cell that has not resumed within this is deadlocked


def send(sock: socket.socket, obj: dict) -> None:
    sock.sendall((json.dumps(obj) + "\n").encode())


def recv(sock: socket.socket) -> dict:
    buf = b""
    while not buf.endswith(b"\n"):
        chunk = sock.recv(4096)
        if not chunk:
            raise ConnectionError("peer closed")
        buf += chunk
    return json.loads(buf)


# --------------------------------------------------------------------------
# Kernel process: one shell reader loop, optionally one control reader thread.
# --------------------------------------------------------------------------
def kernel(shell: socket.socket, control: socket.socket, use_control: bool) -> None:
    namespace: dict = {}
    pending: dict[str, threading.Event] = {}
    replies: dict[str, dict] = {}

    def control_reader() -> None:
        while True:
            try:
                msg = recv(control)
            except (ConnectionError, OSError):
                return
            replies[msg["req_id"]] = msg
            pending[msg["req_id"]].set()

    if use_control:
        threading.Thread(target=control_reader, daemon=True).start()

    def host_request(req_id: str, payload: dict) -> dict | None:
        """Ask the host for an authoritative operation, then block for its reply."""
        pending[req_id] = threading.Event()
        send(shell, {"kind": "host_request", "req_id": req_id, "payload": payload})
        if not pending[req_id].wait(TIMEOUT):
            return None  # never woken: the reply is unreadable from here
        return replies[req_id]

    # The single shell reader loop. One cell runs to completion before the next
    # shell frame is even read.
    orphans = 0
    while True:
        try:
            msg = recv(shell)
        except (ConnectionError, OSError):
            return
        if "kind" not in msg:
            # A host reply that no cell is still waiting for. It was queued on
            # the request channel while the cell that wanted it timed out, and
            # the loop now reads it in a request slot.
            orphans += 1
            continue
        if msg["kind"] == "shutdown":
            send(shell, {"kind": "bye", "orphans": orphans})
            return
        if msg["kind"] == "execute":
            code = msg["code"]
            if code == "set":
                namespace["rows"] = [1, 2, 3]
                send(shell, {"kind": "reply", "cell": msg["cell"], "out": "ok"})
            elif code == "read":
                send(shell, {"kind": "reply", "cell": msg["cell"],
                             "out": repr(namespace.get("rows"))})
            elif code == "delegate":
                got = host_request(msg["cell"], {"op": "spawn_child"})
                send(shell, {"kind": "reply", "cell": msg["cell"],
                             "out": "deadlocked" if got is None else got["handle"]})


# --------------------------------------------------------------------------
# Host side.
# --------------------------------------------------------------------------
def run(use_control: bool) -> dict[str, str]:
    shell_host, shell_kern = socket.socketpair()
    ctrl_host, ctrl_kern = socket.socketpair()
    proc = mp.Process(target=kernel, args=(shell_kern, ctrl_kern, use_control))
    proc.start()
    shell_kern.close()
    ctrl_kern.close()
    results: dict[str, str] = {}

    def execute(cell: str, code: str) -> str:
        send(shell_host, {"kind": "execute", "cell": cell, "code": code})
        while True:
            msg = recv(shell_host)
            if msg["kind"] == "host_request":
                # The host answers on whichever channel this run is configured for.
                reply = {"req_id": msg["req_id"], "handle": "child-7f3a"}
                send(ctrl_host if use_control else shell_host, reply)
                continue
            return msg["out"]

    results["c1"] = execute("c1", "set")
    results["c2"] = execute("c2", "read")       # state survives the turn boundary
    results["c3"] = execute("c3", "delegate")   # the cell awaits a host reply
    send(shell_host, {"kind": "shutdown"})
    results["orphans"] = str(recv(shell_host)["orphans"])
    proc.join(timeout=5)
    if proc.is_alive():
        proc.terminate()
        proc.join()
    for s in (shell_host, ctrl_host):
        s.close()
    return results


if __name__ == "__main__":
    mp.set_start_method("fork")
    print("two kernel processes, real socketpairs, one shell reader loop each\n")

    same = run(use_control=False)
    print("reply on the request channel")
    print("  cell 1 (set state)        :", same["c1"])
    print("  cell 2 (read state)       :", same["c2"])
    print("  cell 3 (await host reply) :", same["c3"])
    print("  orphaned frames on shell  :", same["orphans"])

    split = run(use_control=True)
    print("\nreply on a separate control channel")
    print("  cell 1 (set state)        :", split["c1"])
    print("  cell 2 (read state)       :", split["c2"])
    print("  cell 3 (await host reply) :", split["c3"])
    print("  orphaned frames on shell  :", split["orphans"])

    # --- assertions -------------------------------------------------------
    # 1. Kernel state outlives the cell that created it. This is the whole
    #    reason the interpreter is durable rather than per-call.
    assert same["c2"] == "[1, 2, 3]" and split["c2"] == "[1, 2, 3]"

    # 2. Same channel: the cell is never woken. The reply is sitting unread in
    #    the shell buffer because the only reader is the loop inside the cell.
    assert same["c3"] == "deadlocked", same["c3"]

    # 3. Separate channel: the cell resumes and gets an admission handle.
    assert split["c3"] == "child-7f3a", split["c3"]

    # 4. Adversarial: the deadlock is NOT the host forgetting to reply. The host
    #    replied in both runs; only the channel differed.
    assert same["c1"] == split["c1"] == "ok"

    # 5. The damage outlives the timeout. That reply is still queued on the
    #    request channel and is read into the next request slot, so the failure
    #    is a corrupted request stream, not one slow cell.
    assert same["orphans"] == "1", same["orphans"]
    assert split["orphans"] == "0", split["orphans"]

    print("\nall assertions passed")

Executed output:

two kernel processes, real socketpairs, one shell reader loop each

reply on the request channel
  cell 1 (set state)        : ok
  cell 2 (read state)       : [1, 2, 3]
  cell 3 (await host reply) : deadlocked
  orphaned frames on shell  : 1

reply on a separate control channel
  cell 1 (set state)        : ok
  cell 2 (read state)       : [1, 2, 3]
  cell 3 (await host reply) : child-7f3a
  orphaned frames on shell  : 0

all assertions passed

Three things in that output are worth stating plainly. The host replied in both runs, so the deadlock is not a missing response; only the channel differed. The damage outlives the timeout: the unread reply is still queued on the request channel and is consumed in the next request slot, which corrupts the stream rather than merely delaying one cell. And the same namespace answered cell 2 after cell 1 wrote it, which is the durability the whole design rests on.

The generalisable rule: a reply to an operation that a running cell is awaiting must not travel on the channel whose consumer is blocked by that cell. Because the control-channel handler may run on a different thread than the interpreter's event loop, waking the awaiting task has to be done with a thread-safe scheduling primitive rather than by touching loop state directly.

How to maintain it

The second wire-level rule concerns correlating frames with executions. Output frames carry the message id of the request that produced them, and a host that accepts output from a retired execution will attribute one cell's stdout to another. So the host filters on that correlation.

That filter is correct for output and wrong for channel-open frames. Asynchronous work started by a cell can open its channel after that cell has already returned to idle, so its frame carries a parent nobody is waiting for. Filter it the same way and the delegation vanishes, counted as ordinary staleness.

"""kernel_dispatch.py -- the two rules a host-side kernel dispatcher needs, and
why applying them in the wrong order silently loses delegations.

Rule A (signature): every frame is HMAC-signed; reject on mismatch.
Rule B (correlation): stray output from a retired execution is dropped by
                      comparing parent_header.msg_id against the active cell.

Rule B is correct for output and wrong for delegation. An async task started by
cell N can open its channel after cell N has already gone idle, so its frame
carries a parent_header nobody is waiting for. Filter first and it disappears.

Standard library only (hmac, hashlib, json).
"""

import hashlib
import hmac
import json

KEY = b"a4f1c2de-session-key"


def sign(header: dict, parent: dict, metadata: dict, content: dict) -> str:
    """Jupyter wire signature: HMAC-SHA256 over the four JSON frames, in order."""
    mac = hmac.new(KEY, digestmod=hashlib.sha256)
    for frame in (header, parent, metadata, content):
        mac.update(json.dumps(frame, sort_keys=True).encode())
    return mac.hexdigest()


def frame(msg_type: str, parent_msg_id: str, content: dict) -> dict:
    header = {"msg_id": f"m-{msg_type}-{parent_msg_id}", "msg_type": msg_type}
    parent = {"msg_id": parent_msg_id}
    metadata: dict = {}
    return {
        "signature": sign(header, parent, metadata, content),
        "header": header,
        "parent_header": parent,
        "metadata": metadata,
        "content": content,
    }


def verify(f: dict) -> bool:
    expected = sign(f["header"], f["parent_header"], f["metadata"], f["content"])
    return hmac.compare_digest(expected, f["signature"])


def dispatch(frames: list[dict], active_msg_id: str, comm_first: bool) -> dict:
    """Return what the host accepted. `comm_first` exempts channel-open frames
    from the correlation filter, as the ordering rule requires."""
    accepted = {"output": [], "comm": [], "rejected_sig": 0, "dropped_stale": 0}
    for f in frames:
        if not verify(f):
            accepted["rejected_sig"] += 1
            continue
        is_comm = f["header"]["msg_type"] == "comm_open"
        if comm_first and is_comm:
            accepted["comm"].append(f["content"]["target"])
            continue
        if f["parent_header"]["msg_id"] != active_msg_id:
            accepted["dropped_stale"] += 1
            continue
        (accepted["comm"] if is_comm else accepted["output"]).append(
            f["content"].get("target", f["content"].get("text"))
        )
    return accepted


if __name__ == "__main__":
    # Cell 3 is the live execution. Cell 2 has already gone idle.
    live, retired = "cell-3", "cell-2"

    stream = [
        frame("stream", live, {"text": "building index"}),
        # An async task scheduled by cell 2 opens its channel after cell 2 went
        # idle. Its parent_header still names cell 2.
        frame("comm_open", retired, {"target": "host.request"}),
        # Genuinely stale output from the retired cell. This one SHOULD go.
        frame("stream", retired, {"text": "leftover chatter"}),
    ]

    tampered = frame("stream", live, {"text": "building index"})
    tampered["content"]["text"] = "rm -rf /"        # content edited after signing
    stream.append(tampered)

    correct = dispatch(stream, live, comm_first=True)
    naive = dispatch(stream, live, comm_first=False)

    print("comm exempt from the correlation filter (correct order)")
    print("  output accepted :", correct["output"])
    print("  channels opened :", correct["comm"])
    print("  stale dropped   :", correct["dropped_stale"])
    print("  bad signature   :", correct["rejected_sig"])

    print("\ncorrelation filter applied to every frame")
    print("  output accepted :", naive["output"])
    print("  channels opened :", naive["comm"])
    print("  stale dropped   :", naive["dropped_stale"])
    print("  bad signature   :", naive["rejected_sig"])

    # --- assertions -------------------------------------------------------
    # 1. Signature check catches the edited frame in both orderings. Editing
    #    content after signing must not be silently accepted.
    assert correct["rejected_sig"] == naive["rejected_sig"] == 1

    # 2. The correlation filter does its job on real stale output in both.
    assert correct["dropped_stale"] == 1
    assert correct["output"] == ["building index"]

    # 3. The finding: filtering comm frames by parent loses the delegation
    #    entirely, and loses it silently -- it is counted as ordinary staleness.
    assert correct["comm"] == ["host.request"]
    assert naive["comm"] == []
    assert naive["dropped_stale"] == 2

    # 4. Adversarial: the two orderings agree on everything except the comm
    #    frame, so a test that only checks output would pass on the broken one.
    assert correct["output"] == naive["output"]

    print("\nall assertions passed")

Executed output:

comm exempt from the correlation filter (correct order)
  output accepted : ['building index']
  channels opened : ['host.request']
  stale dropped   : 1
  bad signature   : 1

correlation filter applied to every frame
  output accepted : ['building index']
  channels opened : []
  stale dropped   : 2
  bad signature   : 1

all assertions passed

The adversarial assertion is the useful one: both orderings accept exactly the same output and reject exactly the same tampered frame. A test suite that checks stdout handling passes on the broken dispatcher. Only a test that asserts the delegation was registered catches it.

Beyond dispatch, three maintenance concerns recur.

Environment resolution and staleness. The interpreter needs a Python that can host the kernel package plus whatever skills install into it. A harness typically resolves an operator override first, then a managed environment it bootstraps itself, then a fallback location when the preferred path is not writable. Write a bootstrap marker recording what was installed, and check it on start, otherwise an environment upgraded underneath you fails at first skill import rather than at bootstrap.

Execution is serialised; concurrency is not. One interpreter has one namespace and runs one cell at a time, so execution requests queue. That is correct and should not be worked around with a second kernel sharing the namespace. Concurrency comes from delegation to child sessions, each with its own runtime, not from parallel cells.

Namespace snapshots. A session that is expected to survive a restart needs the namespace persisted alongside the transcript. This is best-effort by nature: object graphs holding sockets, file handles, or live client objects will not serialise. Treat a snapshot as an optimisation that shortens recovery, never as a guarantee, and make sure the agent's own instructions say that recreating state from files is the reliable path.

How to run it in production

Run the interpreter as a separate process from the session host so that a segfault in a native library takes down the kernel and not the agent. Bind exactly one interpreter to one session and let the host restart it on death; a restarted kernel with an empty namespace is recoverable, a shared kernel with two sessions writing the same names is not.

Because the interpreter is not a sandbox, containment has to come from outside it. In practice this means the entire worker runs inside whatever isolation you actually trust: a container with a restricted mount set, a VM, or a disposable machine. Deciding this per workspace is the operational rule: trusted repository on the developer's own box is one risk posture, an untrusted pull request is another, and the kernel boundary does not distinguish them.

Two things are worth exporting as metrics: the age and restart count of each session's kernel, and the queue depth of pending executions. A rising restart count usually means model-generated code is exhausting memory, and a persistently non-zero queue depth means the agent is trying to use the kernel as a concurrency primitive when it should be delegating.

Store session artifacts under a per-session directory with owner-only permissions. The namespace snapshot in particular is a serialised dump of whatever the agent was working on, which may include secrets it read from the environment.

Failure modes

Failure Symptom What to do
Host reply routed to the request channel Cell hangs until timeout, then the request stream desynchronises Route replies for in-flight operations on the control channel; see the first executed block
Channel-open frames filtered by parent id Delegation silently never registers; output handling looks fine Handle channel-open frames before the correlation filter
Managed interpreter environment stale or missing First skill import fails mid-task rather than at start Bootstrap marker checked at kernel start, rebuild on mismatch
Agent expects shell-cell environment changes to persist Commands behave differently between turns Document the asymmetry: interpreter state persists, subshell state does not
Namespace snapshot fails on unserialisable objects Restart loses more state than expected Treat snapshots as best effort; instruct the agent to persist durable results to files
Kernel treated as a security boundary Model-generated code reaches credentials and the host filesystem Sandbox the whole worker; never rely on the kernel process for isolation
Two sessions sharing one interpreter Name collisions and cross-session data leaks One kernel per session, enforced by the host
Agent queues parallel cells for speed Requests serialise, no speedup, higher memory Delegate to child sessions instead

References

  • Jupyter messaging specification (wire protocol, channels, HMAC-signed frames, parent_header correlation): https://jupyter-client.readthedocs.io/en/stable/messaging.html
  • Kernel-backed harness architecture overview (client, supervisor, worker, session, kernel, provider, storage boundaries): https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/docs/architecture.md
  • Documentation index for the same harness (programming model, sessions, skills, compaction): https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/docs/index.md
  • Interpreter-runtime design notes (channel roles, typed host requests, kernel lifecycle, namespace snapshots, depth policy): https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/docs/rlm-runtime.md
  • Programming-model document (single execution tool, durable state, skills as callables, trust model): https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/docs/rlm.md
  • Repository the above documents are published in, read at commit a18809e (2026-08-07): https://github.com/PrimeIntellect-ai/prime-agent

Related: Agent harness architecture · Tools and function calling · Object-oriented agents · Recursive agent delegation · Detached agent sessions and supervision · Agent sandboxing and isolation · When to compact · Context and memory · Agent skills · Policy engine · The harness effect · Glossary