Files
nisse/docs/Architecture.md
T
dongarandClaude Opus 5.5 e2d55e1caf
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m52s
CI / e2e (pull_request) Successful in 4m55s
Architecture X3: avoid, not reject, search-then-dispatch meta-tools
A special-purpose MCP server that works that way may still be worth it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-13 19:11:49 +00:00

56 KiB

Nisse — Architecture

Status: agreed (PR #83); revised in PR #90. How and in what order it gets built is in docs/Development-Plan.md, which also holds implementation notes too detailed for this document. The former docs/Architecture-Build-Spec.md is in git history.

This document records decisions and the reasons for them: what Nisse is, how its parts fit together, and why. Each rule is stated once, in its home section, and referred to elsewhere by section ID.


Naming

The project is Nisse, after the Nordic farm spirit that keeps the place running overnight and expects to be treated well in return.

Artifact Name Role
Shared library nisse-core What every agent is built on (Part L)
Dev Agent nisse-dev Designs and builds software projects (Part D)
Sysadmin Agent nisse-ops Operates infrastructure (Part O)

More agents are planned (3D design work, video-file management), each another application over nisse-core. One string names all of an agent's artifacts (package, image, hostname, Matrix identity, service account), so grepping the infrastructure for nisse-ops finds everything. Sites are named by their domain stem: garrett-hall, hartman-hut.

Everything lives in one repository, nisse. The repository was never a trust boundary (the hosts are, X1), and a monorepo saves a solo operator tripled admin.


Part P — Principles

P1. What Nisse is

Self-hosted agents that do real work and talk about it. Each is a collaborator the operator converses with in Matrix: it explains what it is doing and why, answers questions, asks when unsure, and summarises what it did. It is not a command interpreter behind a menu of keywords.

The agents run local-first (P7) and self-hosted: everything that can run inside the operator's network does, and no outside provider holds state the system needs to resume.

P2. Threat model

The checks exist to stop mistakes and manipulation from doing damage, not to contain a model that schemes against the operator. In order of how often they matter:

  1. Honest mistakes: a wrong command, a misread instruction, confidently wrong code, an action on a stale belief. They call for reversibility, visibility and a second look before anything irreversible (P3, P5).
  2. Prompt injection: untrusted content (issue text, repository files, dependencies, web pages, alerts, logs) steers a well-meaning model. It calls for hard boundaries where untrusted input meets real power (P4, L11).
  3. Crashes and environment surprises: a process dies mid-action, a host reboots, a workspace is corrupt. They call for durable state and idempotent effects (L8).

No check relies on an agent's intentions: one that assumes honesty fails under injection, and one built to contain a hostile model makes the agent useless.

P3. Actions by their effects

Every action is harmless, contained or dangerous:

Class What it is Examples Rule
Harmless Changes nothing outside a disposable sandbox Reading, querying, searching, running tests in a throwaway environment Always allowed
Contained Stays within the agent's own area, or has a known undo Editing the workspace, committing to the agent's branch, opening a PR, writing its own notes; for ops, an approved step with a known undo Allowed in scope; logged and reported
Dangerous Irreversible, visible beyond the agent's area, or outside what was agreed Merging to a default branch, deleting data, changing a firewall, installing on a host the agent doesn't own, contacting third parties, spending beyond a budget Needs discussion, unless an approved step sequence (O2) or the operator in conversation names the exact action

In scope means within what the operator agreed to (L3). The same action can be contained in one work item and dangerous in another. Make actions less dangerous wherever it is cheap (P5).

P4. Three layers of control

  1. Hard walls, outside any model: credentials the agent doesn't hold, network destinations it can't reach (X2), branches it can't push to or merge into, mounts it doesn't have, budget caps, the kill switch, idempotent external effects (L8). They hold even if every model is fooled.
  2. A deterministic fast path: harmless actions, and contained ones a static rule recognises as in scope, proceed without asking any model. Most actions go here.
  3. The action classifier (L4) for everything else.

Conversation sits above all three: when anything needs discussion, the agent asks in plain language and waits. Anything truly irreversible also sits behind a hard wall; the classifier is a filter, not a guarantee.

P5. Reversibility first

  • Work happens on branches, never on what others depend on (D4).
  • Work starts from a fresh, clean workspace (D3). A broken environment is fixed the usual ways, and replaced when that doesn't work: the agent may ask for its build VM to be reimaged rather than diagnose it at length.
  • What must survive lives on the agent host and is snapshotted (X4), and snapshots are taken before risky changes where the platform allows. Tool VMs, build artifacts and caches are disposable and rebuilt rather than repaired (X1).
  • Where a tool offers a dry run or preview, the agent uses it first.

P6. Structural, not model judgment

Anything safety or recovery depends on is enforced in code, never left to a model noticing. A good outcome that only happened because a model behaved well is a bug. Examples: adopting an already-open PR after a crash (L8), binding approvals to what they were about (L3), harness-owned branches (D4).

Models are trusted outright only where a wrong choice costs time rather than safety: choosing the next ready item (L1), judging whether a report is clear (D1), naming a milestone (D4).

P7. Local first, frontier on call

  • A local model drives every step (conversation, planning, implementation, review), on the operator's own inference host behind LiteLLM (L9).
  • Frontier models are helpers the driver calls: a quick question (ask_expert) or a scoped sub-task (delegation), L9.
  • Escalation doesn't wait for the model to notice. The driver is prompted to ask for help when unsure, which is its own judgement; observable triggers (repeated failures, caps) escalate as well, even when it doesn't ask, since small models are poorly calibrated about their own competence.
  • This is an experiment with a cheap exit: if a local model falls short at some kind of work, a frontier model drives it instead, as a configuration change.

Part L — The shared library (nisse-core)

Everything in this part is used by every agent. In code it is the nisse_core package: the transcript (L7) and its renderers, the LLM client and model catalog (L9), context fitting and per-role views (L6), the state store and reconciliation (L8), transports (L5), SSH execution (L13), the vault and memory (L10, L6.4), logging and secrets loading (L12), and the Gitea client (L1). An agent extends the store with its own tables, roles and views; the loop is still nisse-dev's until a second agent shares it.

L1. The work model

Concept What it is nisse-dev nisse-ops
Duty A standing responsibility that produces work items Watching for issues assigned to it (D1) Scheduled checks, log monitoring, alert intake
Work item One effort, opened and eventually closed A bug, feature or new project Diagnosing a failure; making one change
Scope What a work item operates on; context that outlives it A repository, or general A site and service, or general
  • general is the scope of conversation not (yet) about a project: feasibility, hardware, how something works. Each conversation is its own item, one per thread (L5), so unrelated chats stay out of each other's context. It has no repository, no build VM and no execution, so its context is the conversation and agent memory (L6). It closes after a day idle, with a summary in its log; a later reply in its thread starts a new item linked to it. If it turns into work, the work moves to a project's item (D1).
  • Sources of work items: duties, the operator (always), and other agents. A request from another agent is an issue assigned to the receiving agent, and carries no authority of its own: the receiver applies its own rules.
  • A duty run is read-only; a finding that needs attention becomes a work item, deduplicated by a stable fingerprint so a repeat attaches to the open item.
  • A work item is the unit of state: its conversation, proposals, pending questions, pinned roles (L9) and transcript (L7). Its locally minted ID is also its correlation ID in logs.
  • One tracker: Gitea issues, for every agent. An issue assigned to an agent is a work item for it, and the issue is the item's record (D1, O5). External truth wins on divergence (L8).
  • Work items don't nest: a milestone is a phase of one; a delegation is an episode in one.

States.

State Waiting for
active nothing; it is being worked on
ready its turn
waiting on the operator an answer, a go-ahead, a review or a merge
blocked a recorded dependency to close
  • Blocked comes from external truth: a Gitea issue dependency, including on an issue in another repository, such as a request to another agent. It becomes ready only when the dependency actually closes (P6); the operator can remove the dependency. An agent that finds a blocker mid-work proposes recording it, and records it only with the operator's agreement.

One active work item per agent; the rest are parked.

  • Switching happens only at safe points: the active item is waiting, blocked or closed, and no intent is unresolved (L8).
  • Conversation is not work. The agent answers any message about any item between turns, without switching. A reply may change an item's state (an answer makes it ready); only the active item has tools run for it.
  • The agent switches on its own when the active item has waited on the operator past a grace period (so a watching operator can answer first), is blocked, or has closed. The operator can switch at any time.
  • Next item: the operator's order when given ("do #14 next", a priority label), otherwise the oldest ready item. An agent may let the driver choose from code-rendered summaries where judgement helps (ops, by severity); choosing grants nothing.
  • Parking gives up authority: elevated credentials are unmounted (O2) and remounted only if the approval hasn't expired.
  • Nothing is lost while parked: intake for it waits on a durable cursor (L8).
  • "Active" is a pointer, so allowing more than one later (Q) is a loop change, not a migration.

L2. The agent loop

Each agent runs one conversational loop, driven by the local model: it talks with the operator, reads and writes the work item's documents, calls tools and helpers, and reports. It is not a fixed workflow of states.

What is fixed: which item is active (L1), pending questions (recorded durably, asked again after a restart), permissions (L3), and work in progress as intents (L8).

  • Every wait is explainable and leavable: for the operator (with something they can see), for work the agent started, or for a recorded dependency. Anything else is a bug, which the model checker (specs/) guards against.
  • stop works in every state: it ends the current turn or delegation at the next tool boundary, unmounts elevated credentials, and asks what next.
  • abandon ends a work item from any waiting point, closing its record and any open PRs with a note and aborting its unresolved intents.
  • Every action passes through the three layers (P4).

L3. Authority: scope and approvals

Scope is what the operator agreed to, and what actions are checked against (P3). The loop doesn't stop at gates for their own sake.

  • Dev work: the assigned issue and what was agreed in its conversation (D1). Documents inform it but don't widen it (D2). The real check is the PR review.
  • Ops work: an approved step sequence, exactly (O2).
  • Always discussed first: any dangerous action not named by an approved step sequence or by the operator in conversation.
  • Labels and words steer, and are never required (D1): leaving one off is never an error.

Approvals bind to exactly what was approved. This covers everything put up for agreement: a restatement awaiting a go-ahead (D1), a design (D2), a step sequence (O2).

  • Every proposal revision carries a tag, numbered across the work item: rev-N names exactly one.
  • A reply that names no revision is about the one up when it arrived. If that isn't the current one, the reply isn't applied and the agent says which is up. A repeated approval, one on two channels, or an afterthought can never land on the next proposal. (Found by the model checker.)
  • Approval messages are rendered by code from what will happen, never from a model's description (L11).
  • Approvals expire where the world can change underneath them (O2).

Approval sources: Matrix (L5) and Gitea comments (D1, O5), each checked against its own operator allowlist. The first valid approval wins and is echoed to the other channels. The knowledge vault is never one (L10).

The trust dial: how much the operator reviews after the fact, set by the operator per project (e.g. whether the agent may merge its own PRs once checks pass, D5). The agent never changes it.

L4. The action classifier

An independent model call that sorts an action the fast path doesn't cover: safe (proceed), dangerous but in scope (proceed, logged and reported), or dangerous, needs discussion (ask).

  • It judges the action, not the actor's story: its input is the structured action and the agreed scope (L3), never the actor's transcript or untrusted content, which could argue past it.
  • It fails closed: an error, timeout or unclear verdict means needs discussion.
  • It is independent of the actor: its own prompt and context, ideally a different model.
  • Every verdict is logged. Operator overrides become an evaluation set (L9), and verdicts it keeps agreeing on become fast-path rules.

It is shared by all agents; each contributes its own fast-path rules (D6, O3). It executes nothing, so it runs on the agent host.

L5. Conversation and transport

Matrix is the conversation channel: a self-hosted homeserver, one identity and room per agent, behind a Transport interface so another chat system is one new class. Existing Matrix clients are the UI.

A thread per work item. Each item's conversation is a Matrix thread, and the thread is how the agent knows which item a message is about.

  • When a thread starts: with the item. For an assigned issue, the agent's acknowledgement ("queued myproject#14") is the thread's first message. For a conversation, the operator's own top-level message is, and the agent replies in a thread under it. The item records its thread, so the mapping survives restarts.
  • Inside a thread, every message and command (go, stop, abandon, an answer) applies to that item, even while it is parked.
  • At top level, status is about the agent and stop stops the active item. A command that names its item applies to it ("abandon #14"). Any other command is ambiguous, so the agent asks which item rather than guessing. Any other message starts a new conversation, and the agent offers to continue an existing item if it clearly belongs there.
  • Every conversation is a work item from its first message, in the general scope until it is about a project (L1), however vague ("I'm having trouble with X…"). Chit-chat is simply answered in its thread; only work gets a Gitea issue (D1). It may end with a project, an issue, or just an answer.

What the operator says is plain language, plus a small command vocabulary that works in every state: status, stop, abandon, approve [rev-N], go, and work-item selection.

  • Commands are matched leniently: case, surrounding whitespace, trailing punctuation and a leading mention of the agent don't matter. A message addressed to someone else isn't for this agent.
  • Questions never change state. A question gets an answer, never an interpretation as approval, rejection or command; a misread defaults to asking.
  • One exception: a plain-language go-ahead. "Yep, that's it" in reply to a restatement may be taken as go (D1), because a wrong reading costs only time. The agent says so before acting ("taking that as a go-ahead on rev-2"), and it binds to the revision up on arrival (L3). Everything else still needs explicit approve.
  • Documents as input: pasted, attached as a text or Markdown file, or a note in the vault the operator points to (L10). The message is the instruction; the document is material.
  • Everything the operator says is recorded in the transcript (L7).

What the agent says: it explains as well as acts (a proposal with its rationale and alternatives, a revision with what changed, finished work with what was done, verified and left open); it says what happened to a message it can't act on yet, rather than refusing; progress is prose at a readable rate, with raw tool activity on request; and it asks when unsure, including mid-implementation.

Gitea carries work and records, read as structured JSON (never through a chat bridge). Comments from allowlisted operators are instructions or approvals; anything else is data (L11).

Outages delay, never fail. Intake retries indefinitely with capped backoff and logs the outage; interrupted outbound effects are covered by intents (L8).

L6. Context and memory

1. The working context of one model call is rendered from the log at call time for one role, fitted to that model's window, and never stored. Fitting never drops the system prompt, the current plan or the most recent turns; a call that still doesn't fit fails (L9).

2. The work-item transcript (the log, L7) is durable and scoped to one item: nothing crosses items, and agent-level calls (switching, duty runs) see other items only as code-rendered summaries.

  • Every entry is attributed (operator, driver, helper, orchestrator).
  • The orchestrator writes facts (workspace prepared, milestone started or finished, PR opened or merged, approval given), so models learn what happened instead of inferring it.
  • Each role gets its own view. The driver sees the conversation, facts and helpers' reports; a delegated helper sees its brief, the current plan, the facts, its own work and the driver's work in the milestone as compact narration; the expert sees only its question. No role sees another's turns as its own: whatever isn't the viewer's own arrives labelled with its origin.

3. Scope memory outlives work items: a repository's own documents (D2), with the repository as the authority, read by the driver as it needs them (not put into every item's context); for infrastructure, the world model in the vault (L10). What the agent knows about a project that doesn't belong in its repository is agent memory kept with that project (below). A memory note that disagrees with the repository is corrected.

4. Agent memory: the agent's own notes (the operator's preferences, lessons, recurring decisions and their reasons), one note per fact with an index the harness keeps, in its folder under memory/ in the vault (L10): general notes at its top, a project's under projects/<project>/, written only from that project's items. The model writes, updates and forgets them itself, without asking; each change is said in the thread. The general index and the item's project's are put into the transcript at a work item's start; a note is read before decisions it covers. Visible to the operator, who corrects it through conversation, not by editing it.

5. Never memory: Matrix history, provider-side session state, build artifacts, a previous task's workspace.

Writing memory: when a fact is learned, with its source and time; at work-item close, by a bounded pass of its own (what was decided and learned, into agent memory; repository documents it left out of date are flagged to the operator, since they change in PRs), so it never depends on the model thinking of it; operator corrections faithfully, with attribution. Paraphrase is fine: after a back-and-forth, the gist is what matters.

Using memory: everything read is snapshotted into the transcript with its provenance (source, revision or content hash, time), once per item. Memory is a cache, never authority: facts an action depends on are re-checked against the live system first, and a proposal resting on recalled facts says so. A note contradicted by a check is corrected, not worked around.

L7. The canonical log

Every work item's durable record is a canonical event log in the state store, never a provider-shaped message array. Provider requests are rendered from it at call time.

Event Payload
user_message text as sent, source (channel and event ID)
assistant_text / tool_call / tool_result the model's turns, with canonical call IDs
plan_submitted a proposal put up for agreement, with its revision (L3)
fact what the orchestrator observed or did
delegation_started / delegation_completed brief, model, caps / handback, artifacts, cost, status
classifier_verdict action, scope reference, verdict, model
knowledge_retrieved text as read, source, revision or hash, time
compaction summary, producing model, range replaced
  • Every event carries its work item and actor (L6).
  • Nothing provider-specific is load-bearing: IDs are minted locally, content is stored as bytes, and vendor-opaque reasoning is replayed only within the delegation that produced it.
  • The log is never edited; corrections and compactions are new events.

L8. Durability and recovery

The state store (SQLite) holds each work item's position, permissions, pending questions, cursors, pinned roles and log, never workspaces or artifacts. Every change is committed before the loop continues, and the schema is versioned with a migration hook.

Intents and idempotent effects.

  • Before any external side effect, record an intent; after it, commit it.
  • Every external effect is idempotent against external truth: re-running an interrupted step detects and adopts what already happened (e.g. an open PR for the branch). What the operator has since undone is an answer, not a gap to fill: a PR they closed is reported, never opened again.
  • Intake is taken at most once and answered at least once: a durable cursor per external thread; an item being answered stays marked until done and is redelivered after a crash; comments the agent can't act on yet aren't consumed.

Boot reconciliation, before accepting input:

  1. Run pending migrations.
  2. Reconcile pending intents against external truth: verified → committed; verifiably never happened → aborted; unverifiable → ask the operator.
  3. Adopt what external truth shows (a published PR, an approval posted while down, a dependency closed).
  4. Resume waiting points as they were: pending questions and open proposals are put up again, and watches resume from their cursors.
  5. Resume interrupted work only on evidence: a delegation when the cut is clean; an ops step sequence when the log names the step in flight, its outcome checks out and its approval hasn't expired (O2); anything else is reported with what is known, and the operator chooses. Never a blind resume.

Verification: these rules, and the loop's (L1, L2, D1, D4, D5), are checked by formal models (specs/, Quint) over interleavings of messages, comments, outcomes and crashes. Each counterexample is reproduced against the real code before it is fixed.

L9. Models, roles and helpers

LiteLLM is every agent's only LLM destination. It holds the vendor keys (each agent holds one LiteLLM token), enforces budget caps per key, logs, and routes fallbacks; adding a vendor never touches an agent. One client interface, complete(events, tools, model) → events, over the log; long sessions use vendor passthrough so vendor features aren't lost.

Roles resolve to models in configuration: driver (drives the loop), helper (delegations), expert (ask_expert), classifier (L4). The mapping is pinned when a work item opens, so a configuration change can't split one item across models. Each model carries a capability profile from benchmarks (tools, window, strengths, weaknesses), used whenever one role is briefed about another.

Escalation: the driver calls a helper when it chooses to (its prompt treats asking as normal) and on observable triggers (repeated failures, a context that won't fit, turn or time caps).

Delegation: a frontier model takes over a scoped sub-task with tools.

  • One harness for every vendor, not vendor agent SDKs.
  • Bound by the work item's scope (L3) and the same three layers (P4).
  • On its own branch; abandoning it discards the branch.
  • Hard caps on turns, tokens and time; checkpointed, so it can resume (L8).
  • Hands back a note for the driver (what changed, what is now true, what next): that note and the files are the whole interface.

ask_expert: one stateless question, text answer, with a per-item budget so a confused driver can't circle.

Small models get reduced tool schemas and a tool-call contract the loop enforces itself where the server can't.

Evaluation capture: delegations, ask_expert calls and classifier verdicts are recorded as input/output pairs with outcomes, so changing a model is a re-run, not an argument.

L10. Knowledge vault

Durable knowledge outside any repository (world models, agent memory, the operator's material such as procedures) is a folder of Markdown files on the agent host in the plain format SilverBullet reads (frontmatter, [[links]], tags), so the operator browses and edits it in SilverBullet.

  • Agent memory is under memory/, a folder per agent. Other folders' names and layout grow with need.
  • Every folder has exactly one writer, an agent or the operator, and mounts enforce it (P4): each agent gets the folders it writes read-write, those it may read read-only, and nothing else. No agent reads another's memory; nisse-dev gets no infrastructure material, which would map what its builds are kept from.
  • Links point from memory to shared notes, never back.
  • Access is through control tools (L13); no tool VM sees the vault. The harness writes atomically and records each note's provenance (source, work item, time). It may also keep the vault's history in git; recovery doesn't depend on it.
  • The operator's access is SilverBullet on the vault itself, in its own container beside the agents: one copy, nothing synced. It mounts the operator's folders read-write and the agents' folders read-only, so the kernel keeps one writer per folder even within one SilverBullet space, and it sees nothing of the agent host but the vault. Agents never go through SilverBullet: they keep working while it is down, and their mounts keep them apart.
  • Never an instruction or approval channel. Work is never read out of the vault, and no agent writes there to make another act; agents coordinate through the operator, Matrix or issues (L1). The operator points to a note in conversation when it is input (L5).
  • Corrections go through conversation (L6). Break-glass: stop the agent, edit on the host, restart.
  • Procedures are bound to ops policy entries by path (O2) and read whenever a plan uses the entry; search exists in addition, never instead. A missing bound procedure stops planning that action; other failed reads and writes never block work.

L11. Prompt injection

Any model that reads untrusted content may be steered by it, and no marker reliably separates data from instructions. So the design limits what a steered model can do rather than trying to prevent steering.

  • Hard walls hold against a steered model (P4): nothing a model says grants a permission, widens a scope or reaches a credential it doesn't hold.
  • Instruction channels are operator-scoped: Matrix from allowlisted identities, comments and assignments from allowlisted accounts. When someone else wrote an issue, the operator's assignment is the instruction and the text stays data.
  • Intake is parsed into structured fields in code where possible, and what reaches a model is labelled by source (L6).
  • Approval messages are rendered by code; the classifier never sees untrusted content (L4).
  • Some injection is accepted because it can only reorder work (an alert title biasing which item is next).
  • The lethal trifecta (private data, untrusted content, a way out) is checked per agent. In nisse-dev, untrusted content meets command execution only on build VMs, which hold nothing beyond their project's (D7); nisse-ops has all three legs and relies on its hard walls and step sequences (O2).

L12. Secrets, identity and logging

Secrets.

  • Each agent has its own set, on the agent host, mounted only into its container, never baked into images. An agent may structure its set (nisse-dev by project, D7).
  • The harness hands secrets out per operation, decided by code and configuration, never by a model, and never on a command line.
  • Every value handed out is redacted from tool output before it reaches the transcript, a model or a log.
  • Each MCP server instance has its own minimally scoped credentials (X3).
  • Only credentials are secrets; every other setting is reviewable configuration.

Identity: each agent is its own LLDAP principal in a tightly scoped Authelia group, never the operator's account, with its own account and token in each service it uses (X5).

Logging: structured JSON lines to the host journal and the log stack. Every durable state change emits exactly one line, with a fixed envelope (time, agent, event, work item, cause); every verdict, escalation and external effect is logged. Logs are never load-bearing: nothing reads them to derive state.

L13. Tools and execution

Kind Examples Where it runs
Control tools ask_human, ask_expert, start_milestone, finish_milestone, notes (L10) In the harness; they act only on the agent's own state, conversation and records
Execution tools Commands; reading, writing and listing files On a tool VM, over SSH, through execute(host, …)
MCP tools Proxmox and UniFi APIs In MCP server containers beside the agent (X3)
  • execute(host, command, *, secrets, stdin) is the only way to run anything. There is no local execution path, so the agent host never runs a command a model chose (X1).
  • The harness binds the host, not the model: for nisse-dev, the work item's build VM (D3). Where a model must choose (ops: its tool VM or a device), it picks from the hosts the harness allows for that item; others are refused before connecting.
  • MCP tools are the one deliberate exception: fixed code against an API, with the model choosing only arguments. They still pass through P3, P4 and L4, and their output is redacted (L12).
  • Tests use SSH too, against a throwaway sshd, so the tested path is the production path.

Part D — nisse-dev

Designs and builds software projects, producing reviewed changes in Gitea.

D1. How it works

Work arrives as Gitea issues or conversation, and leaves as reviewed PRs.

Intake.

  • An issue assigned to dev-agent is a work item; assignment says whose job it is. The agent acknowledges it ("queued myproject#14, 1 ahead"). Your issue is the item's record. Only issues filed or assigned by allowlisted operators count (L11).
  • Unassigned issues are backlog, never work, though the agent will discuss them.
  • An issue assigned again after its item closed is a new item, which notes the one before it; an issue whose item is still open, or whose assignment predates the close, stays with that item.
  • Open Gitea dependencies make an issue blocked (L1).
  • Work that starts in conversation gets a record once it is work on a project, not at its first message: the transcript already keeps the conversation. The agent opens an issue, assigned to itself, that carries the work item's ID from the moment it is created. Intake ignores any issue carrying an ID, so a record is never taken for new work, even before the agent has stored the link. This is the only time the agent assigns itself.

Starting an item. The agent reads the project's documents (D2), then restates its understanding in the item's thread: what it takes the request to mean, how it fits, its first step. Labels steer what happens next:

Label After the restatement
bug Proceeds after a grace period (the item waits on the operator meanwhile)
none, or anything else Waits for a go-ahead: go, or a plain-language yes (L5)
discuss Talks it through before anything
design Agrees the design (D2) before any code
  • Until the go-ahead it only looks: reading and checking (versions, builds, tests) run; anything that changes something is refused by the fast path (P4). What the work will need, a toolchain change included, goes in the restatement, so the go-ahead covers it.
  • It also talks first when it finds the report unclear (it asks instead of restating) or can't reproduce a bug. It never fixes a bug it couldn't see.
  • It never classifies an issue itself; it may suggest a label. A missing label costs at most a wait.

The flows.

  • A bug: reproduce (ideally as a failing test), fix, verify, PR.

  • A feature: agree the design in conversation, write a build plan if it's longer than about one PR (D2), work through it as milestones (D4).

  • A new project starts as a general conversation, however vague. Nothing needs Gitea before the repository exists: drafts live in the conversation (a Markdown file in the thread if long).

    1. The proposal: a name, what it's for, and the decisions provisioning depends on (toolchain, so the build VM template; services; secrets; egress). The operator agrees as to a restatement.
    2. A provisioning checklist in the thread: a repository with protection, the agent as a collaborator with write access, a build VM, and the project entry (D8), given as configuration to add. The operator provides them (later nisse-ops, O6).
    3. Verified, not assumed: when the operator says done, the agent checks the repository, a push to a branch, that protection refuses it on the default branch (D4), and the build VM.
    4. The record: a tracking issue ("implement the build plan") whose body is what was decided, distilled, linking the conversation's item. The raw conversation isn't carried into the project's context: it holds ideas that were dropped, and the documents are what gets reviewed and what later work reads.
    5. The architecture lands in one or more PRs, treated as design, then the plan's milestones.

    general never gets a build VM: an experiment that needs one (does a library parse this format?) is a reason to start the project for real.

  • Progress goes on the record issue: facts posted by the harness, prose by the agent when there's something to say.

  • Finishing: the last PR closes the issue on merge ("Closes #N"; earlier ones say "Part of #N"). If no merge closes it, the agent closes it with a note, after checking what is left open.

Filing issues from conversation (later). When the operator asks or agrees, the agent files an issue on any project where it is a collaborator: unassigned, so it can't create work for itself; after checking for duplicates; with a note of the conversation it came from.

D2. Documents

A project's own documents live in its repository:

  • ARCHITECTURE.md: what the project is, how it fits together, and why. Kept current by the agent; a design change updates it.
  • Build plans (e.g. under docs/plans/): how a body of work gets done, as milestones. The agent's working documents, for staying organised across context windows and sessions.

Neither is a gate; both are reviewed in PRs.

  • A small architecture change goes in the PR with the code; the agent may raise a larger one first, and design makes that mandatory. Otherwise it's the agent's judgement, and a wrong call costs a review, not safety.
  • A build plan is written for work longer than about one PR and committed with the work, so every PR shows it. The first PR carries the whole plan, so a wrong direction shows at the first review. The operator doesn't approve plans; the agent asks for feedback when it wants it.
  • A plan specifies; it doesn't implement: per milestone, the goal, interfaces and behaviour, edge cases, and tests as acceptance criteria, with code only where code is the clearest specification. Detail is sized to the driver's capability profile (L9). It may note where the agent will check in, and which milestones change no code.
  • Progress is recorded in the plan itself, milestones marked done by each PR. The framework never parses a plan.

D3. The workspace

Each project has its own build VM (X1), where its workspace lives and its commands run. It is kept between work items (toolchains and package caches stay warm), collected after the project has been idle long enough, and re-created from the template plus the project's recorded toolchains. Work pushed to Gitea survives; an unpushed commit may be lost when a VM is wiped, and that is accepted.

A fresh, clean workspace when a work item starts: fetch, check out a clean tree at the latest default branch, and set aside, never silently discard, anything left over (uncommitted changes, local branches, unpushed commits), reporting it. Build artifacts and caches in the workspace never survive into a new task.

The toolchain step makes the build VM and the project's own files agree: a bounded run of the item's driver model that installs what the project needs and keeps its pins, setup documentation (the README or wherever the project keeps it) and CI configuration in step. The repository is the record: reviewed in PRs, surviving any VM, and the same instructions a person cloning it would follow. It starts two ways:

  • The harness starts it when the build VM isn't set up for the project (new, rebuilt or wiped, which the model can't know): before every run it checks the VM's baseline, the workspace clone (cloned again if gone) and a per-project marker that a finished step leaves. This step sets the VM up from the repository and changes nothing in it; files out of step with reality are reported, for the driver to act on.
  • The driver requests it when the work needs a toolchain change, once the work is agreed (D1); its edits land in the current milestone, so they are reviewed with the work.

Versions are facts the harness reads itself (L6): it runs each tool's version command (version commands only), checks them again before later runs and reports a change, and a PR says what its work was tested with. A rebuilt VM's new host key is trusted only on the operator's word.

The build VM is the agent's to change: installing packages and toolchains there, cleaning caches or reinstalling dependencies to fix a broken build are contained, reported but not asked about. Changes meant to last go into the repository's files through a toolchain step; recurring ones go into the template (X4). Installing on any other host is dangerous.

A broken build VM blocks its projects. When the usual fixes don't help, the driver asks for the VM to be reimaged from its template. The harness pushes the work in progress first, then blocks the VM: nothing runs on any project on it, across restarts, until the operator says vm ready (which also trusts its new host key), and a reimaged VM is set up again by the toolchain step. A VM that can't be reached blocks the same way, whether the check before a run finds it or a run loses it (which ends that run there); it is tried again in the background, and work goes on by itself once it has answered for a while. A VM that answers with a new host key blocks until the operator's vm ready. Questions are still answered while a VM is blocked, without it. The operator recovers a VM for now; nisse-ops later (O5).

D4. Git, branches and PRs

  • The harness owns branches; plans and models never create or switch them.
    • The workspace is never on the default branch: a work item starts on a branch of its own.
    • start_milestone(name) makes the harness cut the milestone's branch; finish_milestone(summary) makes it commit, push, open the PR and record the fact. The model decides when, from its plan.
    • Mistakes are made harmless: finishing without a start cuts the branch then; a second start while one is open is refused; work outside a milestone is still on a harness branch.
  • Wait for the merge between milestones: the next one starts from the default branch only after the previous PR has merged (read from Gitea). Meanwhile the item waits on the operator and the agent does other work. A review comment makes the item ready (D5); a PR closed without merging makes it ready with that fact, and the agent asks what next. Redoing the milestone gets a new branch and PR, and the merge rule still applies to it.
  • Parking pushes the item's work: its branch, and anything uncommitted to a side branch of its own, never onto a PR's branch, which may have merged meanwhile. Another item on the same project starts fresh, and this one resumes where it was.
  • Publish only the work: what the agent changed, counted from where the milestone started, never build artifacts; anything left out is reported.
  • Pushes run on the build VM with a push credential (a deploy key, or a push-only account's token) the model's commands never receive (L12); PRs are opened from the agent's container with its API token, which never reaches a build VM. Gitea is the only copy that has to survive.
  • Publishing is idempotent (L8). A milestone with no changes is done if it wasn't expected to change code; otherwise it's raised with the operator.
  • The agent never owns or administers a repository: whoever does can turn protection off. The operator (later nisse-ops) creates and protects repositories; dev-agent is a collaborator.
  • Protection is verified, not assumed: the agent's token must be refused when pushing to the default branch and when merging without the approval the trust dial requires. A test asserts both.
  • A runnable test service's address goes in the PR description.

D5. Review and merge

  • Operator comments on a PR (conversation and inline) are instructions; the agent answers with commits to the same PR, replying where the comment was made. Intake is as in L8.
  • The PR may have moved on meanwhile. After pushing a fix, the agent checks the PR: if it merged before the fix landed, the fix goes out as a new PR; if it was closed, the agent says so and asks.
  • Merging follows the trust dial (L3), set per project (trust.merge). By default the operator merges. Set to approved, the agent merges a PR once an operator has approved its current commit and its checks pass; branch protection requires that approval from an operators-only whitelist and dismisses stale approvals, so Gitea enforces the rule, and the agent checks at start that its own unapproved merge is refused.

D6. Action classes

Class Examples
Harmless Reading the repository, running builds and tests on the build VM, querying Gitea
Contained (in scope) Editing the workspace; milestone branches, pushes and PRs; commenting on its issues and PRs; filing agreed issues (D1); changing its build VM (D3)
Dangerous Merging to the default branch (unless the trust dial allows), deleting remote branches or others' work, changing repository settings, anything outside the item's repository or build VM

Changing branches by hand is neither: it is refused, with a pointer to the milestone tools, since the harness owns branches (D4). Deleting on the build VM is contained, as changing it is; only what would break the host (system and home directories, dd, mkfs, reboot) is dangerous, since in tests the "build VM" is the test machine itself.

D7. Deployment and isolation

The agent is a container on the agent host (X1) with its state, secrets and API tokens. It reaches LiteLLM, Gitea, Matrix and SSH to its build VMs, nothing else.

Build VMs (one per project, D3) are where untrusted content meets command execution:

  • A real Docker daemon, since projects build and run containers.
  • Sealed: egress only to package sources and Gitea's git endpoint, plus what its project entry (D8) declares its tests need (e.g. LiteLLM for the nisse project itself), reviewed like any allowlist (X2). Ingress only SSH from the agent host. No other route into RFC1918, and none to the agent host or the tailnet; no Tailscale.
  • Their own VLAN, with test services on predictable ports, and test containers on an internal network with no route out.
  • A headless browser reaches only the internal test network, to exercise what the agent built. No SSO.

Secrets are namespaced by project. A build VM's commands receive only their project's, and each secret's configuration says whether the model's commands may see it or only harness operations use it (the push credential). Anything the model's commands can see must be scoped for testing, never production.

Provisioning: build VMs come from a template via Terraform; eventually at nisse-dev's request to nisse-ops (an issue in the infrastructure repository, O5), until then by the operator.

D8. Projects

A project is an entry in the agent's configuration, reviewable and changed by PR like any configuration (L12). An entry is what makes a project known to the agent, and it holds everything per-project the rest of this part refers to:

  • the repository and its default branch;
  • the build VM and the template it comes from (D3);
  • the trust dial (L3) and label defaults (e.g. always discuss first, D1);
  • the project's secrets by name, each marked as visible to the model's commands or harness-only (D7);
  • egress its tests need beyond the default (D7).

For a new project, the agent proposes the entry alongside its provisioning request (D1); the operator merges it when the repository and build VM exist. The agent may propose entries and changes, but never changes the trust dial or a secret's visibility itself.


Part O — nisse-ops

Operates infrastructure across both sites, driven by issues and duties, with stricter controls because live changes have no review step before they take effect.

O1. How it differs

Axis nisse-dev nisse-ops
Undo discard a branch often none (zfs destroy, a firewall lockout)
Scope the assigned issue and its conversation approved step sequences (O2)
Dangerous actions discuss discuss, and exact-step approval
Work shape milestones → PRs short reactive tasks → live changes
Intake issues in the project's repository, Matrix issues in the infrastructure repository, alerts, duties, Matrix
Reach sealed build VMs Tailscale, credentials, both sites

They are separate deployments over one library; one instance playing both would collapse the isolation.

O2. Approved step sequences

Every infrastructure write needs an approved plan, and approval covers exactly the steps written. This is ops' scope (L3) and a hard wall in its own right.

  • Write credentials exist only in elevated MCP instances (X3), mounted after approval; read-only ones are always mounted, so looking around needs no plan.
  • A plan is a list of literal steps: tool and target, full arguments (a config change carries its exact diff), an optional precondition checked just before, an optional expected outcome checked just after, and a retry allowance (default one). Prose explains; it is never scope.
  • The approval message is rendered by code from the steps, flagging anything irreversible, anything touching a protected target, and anything that takes down the agent host (which runs every agent, ops included).
  • Execution is matched against the steps. Reads are always allowed; an elevated call runs only if it exactly matches the next unfinished step (arguments normalised by schema). Anything else halts and asks. A failed precondition or outcome halts without retry.
  • Approvals expire if not started in time.
  • Policy is small, conservative data: a versioned file declaring each MCP instance's privilege, protected targets (the NAS, gateways, the agent host, tool VMs) and each elevated tool's target argument. Anything unclassified is elevated and protected. Server hints can only make a tool stricter. A model may propose entries as a PR; nothing is classified at run time.

Parked until real friction shows the shape: bounded matching (calls within an approved target and action, for reversible tools), and standing approvals (a template with a trigger, rate limit and expiry, for a routine fix).

O3. Action classes

Class Examples
Harmless Metrics, logs, status, read-only MCP calls, issues, the world model
Contained (in scope) Approved steps with a known undo; its own world model; commenting on and filing issues in the infrastructure repository
Dangerous Every other infrastructure write; browser actions on admin UIs (each confirmed); anything on network gear, the most conservative case, since a bad change can sever the path to fix it

O4. Interfaces

  • MCP servers for Proxmox and UniFi at both sites: the primary path (X3).
  • A browser for admin UIs without an API; only a fallback for network gear.
  • SSO as svc-nisse-ops, whose blast radius is its Authelia scope.
  • Event intake as structured JSON (directly or via Alertmanager); curated updates go to #ops.

O5. Issues and duties

  • One infrastructure repository for both sites, holding ops' issues (it need hold no code). Sites are labels (garrett-hall, hartman-hut), so cross-site work is one issue. Intake works as in D1: an issue assigned to the ops agent is a work item and its record, labels steer, and Gitea dependencies mark it blocked. Matrix is the live conversation.
  • Action is operator-scoped. Issues from duties (findings, opened by ops itself and deduplicated by fingerprint) and requests from other agents (L1, e.g. nisse-dev asking for a build VM, which then waits blocked on that issue) may be picked up and planned, but any write needs a step sequence the operator approves (O2). Until standing approvals exist, that means every provisioning request.
  • Comments on the issue approve its plan (approve rev-N), as an alternative to Matrix (L3).

O6. Deployment

  • A container on the agent host (X1), with its MCP servers beside it.
  • Its own tool VM, long-lived, for network tools and its commands (L13), with Tailscale and the reach they need at both sites.
  • Egress includes the Proxmox and UniFi endpoints at both sites.
  • Provisions build VMs for nisse-dev through Terraform, under O2.

Part X — Deployment (all agents)

X1. The agent host and tool VMs

One agent host, stable and long-lived. Each agent is a container on it with its own volume (state, transcripts), secrets (L12) and network; no agent can reach another's. Its MCP servers run beside it (X3). The vault lives here (L10). It never runs a command a model chose (L13), which is what makes sharing it safe.

Tool VMs are where model-chosen commands run, reached only over SSH from the agent host, each built from a template plus recorded installs and disposable:

  • nisse-dev: a build VM per project (D3, D7);
  • nisse-ops: its own tool VM (O6);
  • a purely conversational agent: none.

Per-agent attribution: everything an agent holds is issued to its own identity, so every upstream action traces to one agent.

X2. Egress

  • Default-deny allowlists, versioned next to the agent's definition, each entry justified by what needs it.
  • Agent containers: the union of what the agent and its MCP servers reach, enforced by Docker networking and the agent host's firewall. The site firewall sees the host as one address; that is acceptable because nothing on the host executes untrusted input.
  • Tool VMs: enforced at the VM, its Docker network and the site firewall on its VLAN, because this is where untrusted input meets execution. They never reach the agent host except as replies on its SSH sessions.
  • The tailnet is 100.64.0.0/10, not RFC1918; deny it explicitly where it isn't needed.

X3. MCP servers

  • One instance per agent, beside it on the agent host: a shared process would share a credential and lose attribution in the upstream audit log.
  • One instance per (service, site, privilege), each with its own credentials, so a wrong-site or wrong-privilege action needs a tool that isn't mounted.
  • Per-task tool profiles mount only what a work item's scope needs.
  • Avoid search-then-dispatch meta-tools over large catalogues (extra round trips for a local driver, opaque arguments, a tool list that defeats caching). A special-purpose server that works that way may still be worth it, as a deliberate exception.

X4. Images, VMs and restarts

  • Stateless images, named volumes for state, on the agent host. Images are built by Gitea Actions and pulled by Watchtower, which never restarts an agent mid-work (an idle flag gates the restart); crashes are handled by L8.
  • Third-party MCP images are digest-pinned and updated deliberately: they hold root-equivalent credentials.
  • Tool VMs come from templates provisioned by Terraform; recurring installs are baked in. They are rebuilt, never snapshotted.
  • The agent host's state is snapshotted (scheduled, and on each approval), and everything restarts on boot.

X5. Supporting services

All self-hosted: Matrix (Synapse), Gitea, LiteLLM, the local inference host, Vaultwarden, LLDAP with Authelia, and SilverBullet for the operator's view of the vault. Each agent has its own account, token and room in each; nothing is shared between agents.


Part Q — Open questions

  • The classifier's model, and how its verdicts are calibrated against overrides.
  • Recognising questions: an explicit marker or classification; a misread asks.
  • How far the local driver goes, measured on real work (P7).
  • One local model for every agent: preferred, since the inference host keeps one resident; otherwise agents take turns. Measured first.
  • Vision, for 3D design and video work: images in tools, the log and the budget.
  • More than one active work item: needs per-item serialisation, an inference scheduler and per-item intents. Build VMs and threads already separate workspaces and conversations.
  • Stacked PRs: starting a milestone before the previous one merges, after experience with waiting (D4).
  • Build VM collection: how long idle, and whether it needs the operator's word.
  • Where secrets come from: by hand, synced from Vaultwarden, or Terraform.
  • How ops' MCP servers reach the second site from a shared host (Tailscale on the host serves every container).
  • Browser access to network gear: standing, or handed off per task.
  • Relocating a stateful MCP server off the agent host, if one ever justifies losing attribution.
  • The state store's engine. SQLite, one database per agent, for now. If scaling or backups call for it, a server database such as PostgreSQL on the agent host (still one database and role per agent).
  • nisse-ops issuing credentials to other agents: an SSH key for a new VM, Gitea credentials for a new project. Writing into another agent's secret set crosses the isolation in L12, so it needs a designed channel and its own approval rule.
  • Releases. Building and publishing artifacts at each release, usually by tag, sometimes from release branches: what the agent may do (a release is visible beyond its own area, so dangerous unless the trust dial says otherwise) and how a build plan names one.
  • Recovering the agent host. It is a single point of failure for every agent, nisse-ops included, so its recovery can't depend on ops running. A recovery plan (rebuilding the host, restoring state from snapshots) is ops work still to be written.