AI Agents·8 min read·

Multi-Agent Handoff Without a Swarm on One VPS

Hand work between specialist agents with a typed envelope and a local queue, not a free-for-all swarm, so ownership stays explicit on one VPS.

NB

Netbay Engineering

Netbay Engineering

On this page

A swarm is a meeting with no agenda. Every model talks, nobody owns the next action, and you pay for the tokens twice. Handoff is the opposite: one agent finishes a bounded job, writes a typed envelope, and another agent with a narrower tool set picks it up. On a single VPS that pattern is a JSONL queue, a file lock, and two systemd services — not an orchestration framework and not a group chat of models.

This post is the production shape of multi-agent work without a swarm. You keep three roles, one envelope schema, and a disk-backed queue on High-Speed SSD in Lucknow. Intel Xeon Platinum cores are plenty; the bottleneck is the remote model API, not local CPU. L3/L4 filtering on the public interface does not protect you from a chatty swarm spending your key, so the cut between agents has to live in your code.

Why swarms fail on a VPS

A swarm typically shares one tool belt and one rolling transcript. That sounds flexible until a researcher agent calls the same shell tool as the deployer, or two agents retry the same refund because both thought they were being helpful. You cannot audit who decided, you cannot bound spend per role, and you cannot restart a crashed worker without replaying the entire conversation.

Handoff forces a cut you can operate:

  • Exactly one agent is active on a task at a time.
  • The envelope is the only memory that crosses the cut.
  • Tools and API keys are granted per role, not per conversation.
  • A nack has a destination: retry, dead-letter, or a human.

That is how you keep blast radius small on a box that also runs your app. A swarm is a product demo. Handoff is an operations contract.

The envelope is the contract

Do not pass a chat transcript to the next process. Pass a document another process can reject with a schema error. Keep the kind field as a closed set. If the next agent cannot handle kind, it nacks instead of improvising. That single rule kills most swarm chaos.

python
# /opt/agents/envelope.py
import json, uuid, time
from pathlib import Path

QUEUE = Path("/var/lib/agents/queue.jsonl")

def put(kind, payload, from_role, to_role):
    rec = {
        "id": str(uuid.uuid4()),
        "ts": int(time.time()),
        "kind": kind,
        "from": from_role,
        "to": to_role,
        "payload": payload,
        "status": "queued",
    }
    QUEUE.parent.mkdir(parents=True, exist_ok=True)
    with QUEUE.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(rec, separators=(",", ":")) + "
")
    return rec["id"]

Keep payload small. A ticket id, a summary of at most 800 characters, and paths to artifacts on disk. Do not embed PDFs or full git diffs in the envelope; store them under /var/lib/agents/blob/id and put the path in payload. kind stays in a closed set: classify, draft_reply, file_bug, request_human. Anything else is a schema bug, not a creative opportunity for the model.

Two workers, one queue, no chatter

Each worker is a long-running process with flock so two instances never steal the same line. A researcher worker may call web_search and write_notes. A patch worker may call git_diff and open_pr. Neither sees the other's keys. There is no agent-to-agent HTTP and no in-memory bus. If the box reboots, the queue is still on disk.

python
# /opt/agents/worker.py
import json, fcntl, os, time
from pathlib import Path

QUEUE = Path("/var/lib/agents/queue.jsonl")
LOCK = Path("/var/lib/agents/queue.lock")
ROLE = os.environ["AGENT_ROLE"]

def take():
    LOCK.touch(exist_ok=True)
    with LOCK.open("r+") as lf:
        fcntl.flock(lf, fcntl.LOCK_EX)
        lines = QUEUE.read_text(encoding="utf-8").splitlines() if QUEUE.exists() else []
        kept, taken = [], None
        for line in lines:
            rec = json.loads(line)
            if taken is None and rec["to"] == ROLE and rec["status"] == "queued":
                rec["status"] = "running"
                taken = rec
            kept.append(json.dumps(rec, separators=(",", ":")))
        QUEUE.write_text("
".join(kept) + ("
" if kept else ""), encoding="utf-8")
        return taken

while True:
    job = take()
    if job is None:
        time.sleep(2)
        continue
    # handle job["kind"], then put() a follow-up or mark done

Set AGENT_ROLE in the systemd unit, not in a shared .env that every worker sources. That is the whole multi-agent topology: two units, one directory, one lock.

Handoff rules that survive 3 a.m.

Write the rules in code, not in a prompt. Prompts drift. Code review does not.

  1. A worker may emit at most one follow-up envelope per job.
  2. Cycles are forbidden: if from equals to, dead-letter the record.
  3. After two nacks, status becomes human and a mail fires.
  4. Tool results over 8 KiB are truncated on the envelope and stored as a blob.

A researcher that wants a patch must put a file_bug envelope, not call git itself. A patch worker that cannot apply a diff must nack with a reason, not open a second model session to "just try something." The supervisor you will meet in the triage post classifies tickets into these kinds. Here the load-bearing idea is the cut.

handoff, not a swarm intake ticket or hook role A writes envelope JSONL queue flock + status role B narrow tools dead-letter if nack x2 human mailbox, not retry storm done or request_human one follow-up envelope max one owner, one kind, one lock queue lives on disk so a reboot is not amnesia

What you measure

Watch queue depth, time-in-role, nack rate, and model spend per kind. If classify is slow, you have a prompt or schema problem. If the patch role nacks, the envelope is missing a field the worker refuses to invent. None of those need a swarm dashboard. A 20-line Prometheus textfile exporter that counts status=queued by to is enough.

Compacting the queue is a cron job, not a clever agent. Once a day, drop records older than 14 days that are done, and keep nacks forever. The JSONL file on High-Speed SSD stays small; the audit trail in a later post can live beside it.

Takeaway: specialist agents connected by a typed envelope and a locked queue give you multi-agent behaviour you can restart, audit, and bound. A swarm gives you a transcript. You can run the queue and both workers on an Ubuntu 24.04 instance in Lucknow DC01 from Netbay — ready in under 60 seconds at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance