MAQPNADocs

Tutorial: build a fork-and-explore agent

Build an agent that plans once, then forks its running session into governed children that each explore a different alternative, with the microVM forked by kosa8 and the model context forked by llamay.

In this tutorial you build an agent that explores alternatives by forking itself. It asks a local model for a migration plan once. Then you fork the running agent twice. Each fork is a new governed session: a copy of the agent's microVM made by kosa8, with its own MAQPNA session token, continuing from the parent's exact model state in a fork of its llamay context. Each session explores one alternative and reports its result. At the end you compare the three answers and read in the audit ledger how the sessions and contexts are related.

You start from an empty folder. Everything runs on your machine; no cloud model or API key is needed.

What you'll build#

flowchart LR
    subgraph host["Your machine"]
        subgraph k["kosa8 microVMs (egress: gateway only)"]
            P["explore<br/>agent.py: plan, then baseline"]
            A["explore-a-1<br/>fork: alternative a"]
            B["explore-b-1<br/>fork: alternative b"]
        end
        GW["MAQPNA gateway<br/>:8080 on the LAN address"]
        L["llamay :11435<br/>qwen2.5:0.5b"]
        LED[("audit ledger<br/>.maqpna/audit.jsonl")]
        P -->|"/llm/llamay"| GW
        A -->|"/llm/llamay"| GW
        B -->|"/llm/llamay"| GW
        GW -->|"ctx_P / ctx_A / ctx_B"| L
        GW --> LED
    end
    P -.->|"maqpna dev fork"| A
    P -.->|"maqpna dev fork"| B
Time About 30 minutes, plus the first image and model downloads
Level Intermediate: you can read Python and use two terminals
You learn Live session fork, how a forked agent learns its new identity, context fork in llamay, result reporting, reading fork lineage in the ledger

Prerequisites#

  • A machine that runs kosa8 sandboxes: an Apple silicon Mac, or x86-64 Linux with KVM. See kosa8 requirements.
  • The maqpna CLI, version 2.1.0 or later. See Install.
  • jq, to read the ledger.
  • About 2 GB of free disk for the model and the python:3.12-alpine image.

Step 1: Install llamay and kosa8#

curl -fsSL https://llamay.com/install.sh | sh
curl -fsSL https://kosa8.com/install.sh | sh
curl -fsSL https://llamay.com/install.sh | sh
curl -fsSL https://kosa8.com/install.sh | sh

kosa8 on Linux needs KVM and access to /dev/kvm. The installer lists what is missing.

Start the kosa8 daemon and pull the model:

kosa8 daemon start
llamay pull qwen2.5:0.5b

Check it worked.

llamay version
llamay models            # lists qwen2.5:0.5b
kosa8 run alpine echo hi # prints hi; if not, run kosa8 doctor

Details and other install methods: Use llamay models with MAQPNA and Run MAQPNA sessions in kosa8 sandboxes.

Step 2: Create the project and start MAQPNA#

mkdir fork-explore && cd fork-explore
maqpna dev up --kosa8 --llamay qwen2.5:0.5b

--llamay starts llamay on 127.0.0.1:11435 as model route llamay, with a context per session and its request audit anchored in the ledger. --kosa8 makes the gateway listen on your LAN address, so agents in microVMs can reach it, and makes the identity broker verify each sandbox with kosa8 before it mints a token.

Check it worked.

maqpna dev status
jq '.models.llamay.provider, .llamay.checkpointDir' .maqpna/gateway.json

The first line is "llamay". maqpna dev status shows the gateway on your LAN address, for example http://192.168.1.20:8080.

Step 3: Write the agent#

The agent runs inside a microVM whose only egress is the gateway, so it cannot install packages. It uses the Python standard library only. Create agent.py:

"""Fork-and-explore agent.

Plans once, then waits HOLD_SECONDS for forks. A fork (maqpna dev fork) is
a copy of this process with a new session token in MAQPNA_TOKEN_FILE. Each
session then explores one alternative, chosen from its session id, and
reports its result.
"""
import base64
import json
import os
import time
import urllib.error
import urllib.request

GATEWAY = os.environ["MAQPNA_GATEWAY_URL"]
CHAT_URL = os.environ["OPENAI_BASE_URL"] + "/chat/completions"
MODEL = os.environ.get("MAQPNA_MODEL_NAME", "default")  # the gateway forces the route's model anyway
TOKEN_FILE = os.environ["MAQPNA_TOKEN_FILE"]
HOLD_SECONDS = 90

TASK = ("We run one MySQL 8 database of 400 GB for an online shop. "
        "Plan its migration to PostgreSQL 16 with less than 5 minutes of downtime.")
ALTERNATIVES = {
    "baseline": "a big-bang cut-over in a maintenance window",
    "a": "logical replication into PostgreSQL, then a blue-green cut-over",
    "b": "moving one service at a time behind a dual-write layer",
}


def token() -> str:
    # Read the token on every call: a fork gets its own token in this file.
    with open(TOKEN_FILE, encoding="utf-8") as fh:
        return fh.read().strip()


def session() -> str:
    # MAQPNA_SESSION still names the parent in a fork; the token is the truth.
    # Only reading our own claims here: the gateway verifies the signature.
    payload = token().split(".")[1]
    payload += "=" * (-len(payload) % 4)
    return json.loads(base64.urlsafe_b64decode(payload))["maqpna_session"]


def post(url: str, body: dict) -> dict:
    req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", headers={
        "Authorization": "Bearer " + token(), "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=300) as resp:
            return json.load(resp)
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode(errors="replace")
        raise SystemExit(f"[{session()}] gateway answered HTTP {exc.code}: {detail}")


def chat(messages: list) -> tuple[str, dict]:
    reply = post(CHAT_URL, {"model": MODEL, "messages": messages, "max_tokens": 200})
    return reply["choices"][0]["message"]["content"], reply.get("usage", {})


def log(sess: str, what: str, usage: dict) -> None:
    cached = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0)
    print(f"[{sess}] {what} (prompt {usage.get('prompt_tokens')} tokens, {cached} cached)", flush=True)


def branch_for(sess: str, root: str) -> str:
    # maqpna dev fork --name <root>-a gives session <root>-a-1.
    if sess == root:
        return "baseline"
    name = sess.removeprefix(root + "-").removesuffix("-1")
    if name not in ALTERNATIVES:
        raise SystemExit(f"[{sess}] no alternative for fork name {name!r} (use --name {root}-a or {root}-b)")
    return name


def main() -> None:
    root = session()
    messages = [
        {"role": "system", "content": "You are a senior database engineer. Answer in at most five short sentences."},
        {"role": "user", "content": TASK},
    ]
    plan, usage = chat(messages)
    messages.append({"role": "assistant", "content": plan})
    log(root, "planned", usage)

    print(f"[{root}] ready: fork me within {HOLD_SECONDS}s with\n"
          f"    maqpna dev fork --session {root} --name {root}-a\n"
          f"    maqpna dev fork --session {root} --name {root}-b", flush=True)
    deadline = time.time() + HOLD_SECONDS
    sess = root
    while time.time() < deadline and sess == root:
        time.sleep(0.5)
        sess = session()

    branch = branch_for(sess, root)
    messages.append({"role": "user", "content":
                     f"Now evaluate this approach: {ALTERNATIVES[branch]}. "
                     "Name its biggest risk and score it from 1 (bad) to 10 (good)."})
    answer, usage = chat(messages)
    log(sess, f"explored {branch}", usage)
    print(f"[{sess}] {branch}: {answer}", flush=True)

    summary = f"{branch}: {answer}".encode()[:1000].decode(errors="ignore")  # at most 1024 bytes
    post(GATEWAY + "/v1/sessions/self/result", {"result": "Succeeded", "summary": summary})
    print(f"[{sess}] result reported", flush=True)


if __name__ == "__main__":
    main()

What the code relies on:

Behaviour Where it comes from
OPENAI_BASE_URL, MAQPNA_MODEL_NAME, MAQPNA_GATEWAY_URL, MAQPNA_TOKEN_FILE are set maqpna dev run in a kosa8 sandbox. The token is only in the file, never in the environment.
A fork gets a new token in the same file maqpna dev fork rewrites /tmp/maqpna/token in the clone with the child's token.
The token's maqpna_session claim names the session The identity broker's token format. A fork also carries maqpna_parent_session.
The second model call reuses the context The gateway binds each session to one llamay context. In a fork, the first call forks the parent's context.
POST /v1/sessions/self/result The session result route the SDKs' report_result() / reportResult() use. summary is at most 1024 bytes.

Step 4: Run the parent session#

In terminal 1, from fork-explore:

maqpna dev run --session explore --kosa8-image python:3.12-alpine --kosa8-keep -- \
  python3 -u -c "$(cat agent.py)"

$(cat agent.py) passes the code as one argument, because the microVM has no access to your folder. --kosa8-keep keeps the sandbox, which dev fork needs. The first run pulls python:3.12-alpine.

You see maqpna dev report that the broker verified the sandbox before minting, then:

[explore] planned (prompt <n> tokens, 0 cached)
[explore] ready: fork me within 90s with
    maqpna dev fork --session explore --name explore-a
    maqpna dev fork --session explore --name explore-b

Step 5: Fork it twice#

While the parent waits, open terminals 2 and 3 in fork-explore. In terminal 2:

maqpna dev fork --session explore --name explore-a

In terminal 3:

maqpna dev fork --session explore --name explore-b

Each prints a line such as:

forked session explore -> explore-a-1 (kosa8 sandbox maqpna-explore-a-1, 1997 ms); the child's model context forks on its first model call

Then it streams the clone's output. The clone was copied mid-loop. Within a second its token file holds the child's token, the loop sees the new session id and the clone explores its alternative:

[explore-a-1] explored a (prompt <n> tokens, <m> cached)
[explore-a-1] a: <the model's answer>
[explore-a-1] result reported

<m> is above zero: the child's first call ran in a fork of the parent's context, so llamay did not prefill the shared plan again. When the 90 seconds are over, the parent in terminal 1 explores baseline and reports its result.

Step 6: Compare the alternatives#

Each session reported its result, so the ledger holds all three answers:

jq -r 'select(.tool=="report_result") | "\(.session)\t\(.ext.resultSummary)"' .maqpna/audit.jsonl

Pick the alternative with the best score and the smallest risk. Each answer came from a separate governed session with its own token, budget roll-up and audit trail.

Step 7: Check the lineage#

Sessions#

maqpna dev timeline --session explore
maqpna dev timeline --session explore-a-1
maqpna dev timeline --session explore-b-1

Each timeline shows that session's model calls on llm/llamay/chat.completions and its result.

Model contexts#

jq -c 'select(.ext.llamayContext) | {session, ctx: .ext.llamayContext, forkedFrom: .ext.llamayContextParent}' .maqpna/audit.jsonl

You see three different contexts. The first model call of explore-a-1 and of explore-b-1 has forkedFrom equal to the context of explore. Both forks started from the same parent state and then diverged.

sequenceDiagram
    autonumber
    participant P as explore (parent)
    participant F as maqpna dev fork
    participant C as explore-a-1 (clone)
    participant G as Gateway
    participant L as llamay
    P->>G: plan (session explore)
    G->>L: POST /v1/contexts, then chat in ctx_P
    F->>C: kosa8 live fork, then new token file
    C->>G: evaluate alternative a (session explore-a-1, parent explore)
    G->>L: POST /v1/contexts/ctx_P/fork, then chat in ctx_A
    G->>G: audit: llamayContext ctx_A, llamayContextParent ctx_P
    C->>G: POST /v1/sessions/self/result
    G->>L: DELETE /v1/contexts/ctx_A

When a session reports its result, the gateway drops its llamay contexts at once.

Counters and the llamay audit#

curl -s "$(jq -r .gatewayUrl .maqpna/state.json)/metrics" | grep maqpna_gateway_llamay_context_ops_total
maqpna llamay verify-audit .maqpna/llamay-audit.jsonl

op="fork" is 2. verify-audit prints llamay audit chain verified: ...: llamay's own record of every request, including the forks, is intact. The gateway anchors the same chain in the ledger:

jq -c 'select(.rule=="llamay/audit-anchor") | .ext' .maqpna/audit.jsonl | tail -1

Step 8: Clean up#

The parent sandbox and the clones were kept. Remove them, then stop MAQPNA:

kosa8 sandbox ls
kosa8 sandbox rm maqpna-explore
kosa8 sandbox rm maqpna-explore-a-1
kosa8 sandbox rm maqpna-explore-b-1
maqpna dev down

What can go wrong#

Symptom Reason Fix
dev fork fails: session explore has no kosa8 sandbox The parent ran without --kosa8-keep, or its sandbox was removed. Run Step 4 again with --kosa8-keep.
The clone explores baseline, or exits with no alternative for fork name The fork came after the 90 seconds, or --name was not explore-a or explore-b. Fork while the parent prints ready. Raise HOLD_SECONDS if you need more time.
The clone's call is recorded as explore The clone runs with the parent's token for up to about a second after the fork, before MAQPNA re-keys it. Expected. The agent waits for the new session id before its next call.
A fork shows no llamayContextParent and 0 cached The parent's context was gone: the parent had already reported its result, or a revocation matched it. Fork before the parent finishes.
gateway answered HTTP 503 with llamay_audit_unverified llamay's audit chain does not verify, so llamay is out of routing (failClosed). Run maqpna llamay verify-audit .maqpna/llamay-audit.jsonl. Restart with maqpna dev down and maqpna dev up.
gateway answered HTTP 401 The agent read a token that expired (default lifetime 1 hour), or an old file. Run again; keep reading the token per call.
The sandbox cannot reach the gateway --kosa8 picked the wrong LAN address. maqpna dev down, then maqpna dev up --kosa8 --kosa8-host-ip <your IP> --llamay qwen2.5:0.5b.

No kosa8? You can still see the context fork without a sandbox: mint a child token with parentSession from the local broker, as in Use llamay models with MAQPNA, Step 6. More cases are in Troubleshooting the stack.

Next steps#