Bionic agent harness base for Go — pure wiring, zero default implementations.
Meowire is a minimal decision-loop kernel for building agent hosts. It wires the orchestration (Think → Act → yield events) and leaves everything else to you: the LLM, the tools, the memory, the security policy. No framework opinion about your stack — just a clean, dependency-free loop you can rely on.
Requires Go 1.27+ (uses
iter.Seq).
- You own the intelligence. Meowire provides no LLM adapter, no tool framework, no memory backend — it injects seven host ports and expects you to implement them. The framework never hides what your agent actually does.
- Zero dependencies. Standard library only. No transitive dependency tree to audit.
- Small and readable. ~4k lines of Go (production code; tests are another ~8.5k). The decision loop reads as one concern
per file (
internal/nerve/: loop, gate, pause, retry, feedback, parallel). - Sealed internals. All implementation lives under
internal/— the Go compiler guarantees the only importable surface is theapi/package (New/Stimulate/Close+ contract types).
- Think→Act decision loop with per-round retry and a hard round limit
- Typed event stream —
Stimulatereturnsiter.Seq[Event]; the host observesEventText,EventToolCall,EventToolResult,EventState,EventDone,EventError,EventUsage,EventSandbox(membrane ruling audit record, either side of the loop),EventWaitInput(loop suspended waiting for external input),EventPaused(pause request honored — snapshot + resume handle),EventReplace(port-swap audit record),EventConfig(config-swap audit record) - Membrane audit trail — every ruling the
Sandboxhands back (allowed, denied or asked, on either side of the loop) yields oneEventSandboxrecord (the gated tool, or a zero tool for a text ruling; plus reason and error); persist the event stream for a complete "who/what/why was permitted" audit, per the Authority security model - Wiring graph inspection —
Connectome/Validate/RenderDiagram/RenderJSONtreat the assembly as a graph (data-object nodes + slot edges) and render it for humans or machines - Dynamic wiring (swap an organ at runtime) —
Agent.Replace(slot, port)swapsThink/Act/Sandbox/Budget/Mem/Hooksat runtime; takes effect at the nextStimulate, an in-flightStimulatekeeps the ports it started with; every successful swap is audited asEventReplaceat the start of the next Stimulate/Resume - Runtime config updates —
Agent.UpdateConfig(cfg)/Agent.GetConfig()tuneMaxRoundsand the other scalar limits hot, without rebuilding the agent - Seven host-injected ports, all required (no stubs, no optional organ — the kernel
does not know a neighbour exists; anything between instances belongs to the host):
Thinker(LLM),Effector(tools),Closer(cleanup),Hooks(interception — all eight callbacks H1–H8 required: explicit no-op, not absence),Sandbox(permission membrane),ContextBudget(token regulator over both accumulating tracks — needs Trimmer, TrimResults and MaxTokens),Memory(experience port —Recallbefore each Think,Rememberonce per invocation) - Several organs behind one port —
GuardStack/FallbackThinker/FallbackEffectorcompose implementations into the one organ the loop sees and return the port type itself, so a composed organ wires like a plain one and the blueprint gained nothing - Organs can be brought up before they are used — any port may declare
Bootable;Newboots each declaring organ once, inPortOrder()(the sequence the blueprint implies, not a list written beside it), andReplaceboots before a swap commits. A failing boot aborts and releases what the attempt opened through the hostCloser— the framework still closes nothing - Step-Resume — each
Stimulateis one stateless step; stop the iterator, do host-side work (async tool, manual takeover,ErrMaxRoundscontinuation), thenStimulateagain. Tool-requested input (ask_user) is not done this way — see Suspension-resume below (the only form) - Unified suspension-resume — one snapshot + resume path for all four suspension flavours:
- ask_user: a tool returns
Effect{WaitInput: question}and the loop yieldsEventState(StateWaiting)+EventWaitInput(tool, question, opaqueSession) and ends the iterator normally — no blocking, no extra round, no budget during the wait.Agent.Resume(ctx, sess, response)continues: the response enters the loop as the pending tool's structured result (Prompt.ToolResultsentry, ID preserved), remaining tools run first, then the loop resumes from the suspended round. - Pause:
Agent.Pause()is honored at gap points (before each Think / tool execution); the loop yieldsEventState(StatePaused)+EventPaused(Session snapshot) and ends the iterator normally —Agent.Resume(ctx, sess, "")continues (no pending tool to inject). A pause before a tool keeps that tool in the snapshot, so Resume runs it first. - Membrane ask: a tri-state ruling —
Sandbox.Allowbefore a call runs, orSandbox.Emitbefore a round's text is heard — yields the same StateWaiting + EventWaitInput pair (the question is the ruling's reason; anEmitask withholds the draft inside theSession). Resolution follows one shared response grammar — "" denies ([sandbox-denied: declined]), a[denied:prefix denies with that text (the feedback lands in the canonical[sandbox-denied: ...]form), any other response approves: the pending call runs without re-gating, or the withheld draft is said as generated without another Think. - Persistent:
Session.Marshal()/UnmarshalSessiongive versioned JSON persistence — a suspended or paused loop survives process restarts (alignment with mainstream checkpoint/resume). Timeouts are host-controlled (default deny); replaces the old host-side synchronous block
- ask_user: a tool returns
- Structured tool feedback — tool results flow back as
Prompt.ToolResults(ToolResult{ID, Name, Result, Err}, single track;call_xxxIDs preserved); rendering (tool-role messages,[tool_call_id=xxx]markers, plain text) is the host Thinker's decision — the text track (Context) keeps host base + sandbox denials - Per-tool timeout & retry —
Config.ToolTimeoutbounds each tool execution;ToolMaxRetriesretries effector errors (business errors inEffect.Errare never retried) - Parallel tool batches (opt-in) —
Config.ParallelActsexecutes a round's multiple independent tool calls concurrently (serial gating → parallel Act → serial feedback in call order); events and hooks stay serial. Off by default; requires a concurrency-safe Effector.MaxParallelActscaps how many calls of a batch run at once.Session.RemainingCalls()exposes the pending calls of a suspension (empty when nothing is left to replay) - The stream is journalable —
EncodeEvent/DecodeEventwrite one versioned JSON record per event, carry enums by name, restore framework errors by identity, refuse an enum its own name table cannot spell, and name anything that could not cross in the event'sDroppedfield; every event carries theCellIDof the cell that produced it, so one log can hold events from several agents - Tri-state rulings on both sides & reflection primitives —
Sandbox.Allow(before a tool runs) andSandbox.Emit(before a round's text is heard) each return aVerdict: Deny (zero value, fail-closed), Allow, or Ask — a value outside those three named states denies the same way; an ask suspends via the same suspension-resume protocol as ask_user and takes effect only after the host approves (one audit chain closes with a terminal resolve record).Hooks.OnCycleEnd(ctx, output, outcome)classifies how every cycle ended (CycleOutcome: Done/Suspended/MaxRounds/ Error/Aborted);BeforeStimulatemay write a turn-scoped self-review note ontoPrompt.Reflection, carried onto every Thinker prompt of the cycle - Host-managed history (MemHop pattern) — context accumulation and memory injection are yours
- Flat multi-agent model — one
Agentis one kernel, and a host that wants several builds several instances; sub-agents stay host tools (spawn_agent), never framework-level nesting - Resistance is feedback, not failure — denied tools and tool errors flow back into the
loop as
EventToolResultfeedback; the loop continues
- Every wiring point is now required — hooks H1–H8 must all be set
(explicit no-op where no behavior is wanted); a missing callback fails
New.SandboxandContextBudgetwere already required; now the loop never tolerates nil —Bounds()snapshot,EventSandboxaudit verdicts andBudget.Trimmerruns are unconditional. Blueprint.Strictremoved — with no warn level left there is nothing to promote; drop the field fromBlueprintliterals.ContextBudgetcompleteness enforced — nil Trimmer or MaxTokens <= 0 fails assembly (a budget that does not trim is not a budget).Replacerejects nil/incomplete ports — swapped organs must be complete.SandboxrequiresBounds() string— return the execution boundary description; the framework snapshots it once perStimulateand surfaces it read-only to hooks and the Thinker viaPrompt.Bounds:func (s *MySandbox) Bounds() string { return "read-only /workspace" }
- Missing-port errors are
errors.Join-aggregated — match witherrors.Is/strings.Contains, never exact string equality.
meowire (module root)
└── api/ facade + composition root — the sole public surface
├── internal/cell agent kernel (ID + ports + DecisionLoop)
└── internal/nerve decision loop, ports (incl. memory), hooks, events, guards
| Concept | Where | Role |
|---|---|---|
Agent / New / Stimulate / Close |
api/ |
Facade: the entire public surface |
DecisionLoop.Cycle |
internal/nerve/loop.go |
Pure orchestration: Think → Act → yield |
Cell |
internal/cell/cell.go |
Minimal kernel: ID + ports + loop |
Thinker / Effector / Closer |
ports | Host-provided capabilities |
Hooks |
ports | BeforeStimulate / AfterStimulate / BeforeThink / AfterThink / BeforeAct / AfterAct / OnError / OnCycleEnd |
Sandbox |
guard | Permission membrane on both sides: Allow before each Act, Emit before a round's text reaches anyone; Bounds() surfaces the execution boundary to the Thinker via Prompt.Bounds |
ContextBudget |
guard | Trims the text Context and the structured ToolResults before each Think, same limit |
Memory |
port | Recalls this round's records into Prompt.Memories; takes the finished cycle's facts back |
Event |
events | Typed observation mirror of the loop |
go get github.com/qyiun666/meowire@latestImport the facade package — the sole public surface:
import meowire "github.com/qyiun666/meowire/api"For the full host-side integration contract — the seven ports, field-by-field semantics, the event stream, and the pitfalls — see the Host Integration Guide.
package main
import (
"context"
"fmt"
meowire "github.com/qyiun666/meowire/api"
)
// thinker implements meowire.Thinker — the LLM port.
type thinker struct{}
func (thinker) Think(ctx context.Context, p *meowire.Prompt) (*meowire.Decision, error) {
return &meowire.Decision{Text: "Hello from meowire!"}, nil
}
// effector implements meowire.Effector — the tool-execution port.
type effector struct{}
func (effector) Act(ctx context.Context, a meowire.Action) (*meowire.Effect, error) {
return &meowire.Effect{Result: "ran " + a.Call.Name}, nil
}
// closer implements meowire.Closer — host resource cleanup.
type closer struct{}
func (closer) Close() error { return nil }
// sandbox implements meowire.Sandbox — the membrane on both sides of the loop.
type sandbox struct{}
func (sandbox) Allow(ctx context.Context, a meowire.Action) (meowire.Verdict, string, error) {
return meowire.VerdictAllow, "", nil
}
func (sandbox) Emit(context.Context, meowire.Utterance) (meowire.Verdict, string, error) {
return meowire.VerdictAllow, "", nil
}
func (sandbox) Bounds() string { return "read-only /workspace" }
// memory implements meowire.Memory — the experience port.
type memory struct{}
func (memory) Recall(context.Context, meowire.MemoryQuery) ([]meowire.Record, error) {
return nil, nil
}
func (memory) Remember(context.Context, meowire.CycleFacts) error { return nil }
func main() {
// Blueprint: define once, New many times (flat-model multi-agent)
bp := meowire.Blueprint{
Organs: meowire.Organs{
Think: thinker{},
Act: effector{},
Closer: closer{},
Hooks: meowire.FullHooks(meowire.Hooks{}), // all eight callbacks, explicit no-ops
Sandbox: sandbox{},
Budget: &meowire.ContextBudget{
MaxTokens: 8192,
Trimmer: func(c []string, max int) []string { return c },
TrimResults: func(rs []meowire.ToolResult, max int) []meowire.ToolResult { return rs },
},
Mem: memory{},
},
Config: meowire.Config{},
}
agent, err := meowire.New(bp)
if err != nil {
panic(err)
}
defer agent.Close()
for ev := range agent.Stimulate(context.Background(), "hello") {
switch ev.Kind {
case meowire.EventText:
fmt.Println(ev.Text)
case meowire.EventToolCall:
fmt.Printf("[tool] %s(%s)\n", ev.ToolCall.Name, ev.ToolCall.Args)
case meowire.EventDone:
fmt.Println("[done]")
case meowire.EventError:
fmt.Println("[error]", ev.Err)
}
}
}Stimulate runs one step of the loop and returns an iter.Seq[Event]. Tool execution happens
inside the loop via your Effector; results enter the next round's Prompt.ToolResults
(structured track). The host observes (EventToolCall / EventToolResult) but never feeds data back
into an open iterator.
Stopping consumption (yield returns false) abandons the round: tools at or after the stop
point do not execute, and all state accumulated in this round is discarded.
OnCycleEnd is guaranteed to run exactly once per Cycle — on success, on error, and on abort — and reports how it ended via its outcome CycleOutcome parameter (Done / Suspended / MaxRounds / Error / Aborted; zero reserved for an iterator abandoned mid-cycle → Aborted).
Stop the iterator, do host-side work (async tool, manual approval, external service), save your own
progress, then call Stimulate again. Each Stimulate is a stateless step — this is the way to
implement host-driven takeover, long-running tasks, and retries. Tool-requested input (ask_user)
is not done this way: a tool returns Effect{WaitInput: question} and the loop suspends with an
opaque Session — resume it via Agent.Resume(ctx, sess, response) (see Suspension-resume above).
The two paths are mutually exclusive; a break-based ask_user would lose the suspended context
(the Session is opaque and cannot be rebuilt by hand).
All four suspension flavours — tool-requested input (ask_user), a membrane ask on either side of the loop
(VerdictAsk), and host-requested pause — share one
mechanism: the loop yields a suspension event carrying an opaque Session snapshot and ends the
iterator normally; the host saves the Session (optionally persisting it via Session.Marshal() /
UnmarshalSession for cross-process recovery), then calls Agent.Resume(ctx, sess, response) to
continue from the suspended point — no extra round, no budget during the wait.
- ask_user:
Effect{WaitInput: question}→EventState(StateWaiting)+EventWaitInput; the response is injected as the pending tool's structured result. - Pause:
Agent.Pause()honored at gap points →EventState(StatePaused)+EventPaused;Resume(sess, "")continues without injecting anything (no pending tool). A pause before a tool keeps that tool (and the calls after it) inSession.remaining, so Resume runs them first. - Membrane ask:
Sandbox.AlloworSandbox.EmitreturningVerdictAsk→ the sameEventState(StateWaiting)+EventWaitInputpair (question from the ruling's reason, and anEmitask carries noCall); the response grammar matches ask_user. Agent.Resumeclears a stale pause request automatically;Agent.Unpause()only backs out a pause request that has not taken effect yet.- The Session is single-use: resuming it twice re-executes the remaining tool calls (host responsibility). A pause never interrupts a running Think/Act: it is honored at a gap point.
Config.MaxRounds (default 8) is a hard cap. If the last round still has pending tool calls,
the loop ends with ErrMaxRounds — the tool results from that round were never re-thought.
Use Step-Resume to continue from where the loop stopped.
The framework never stores anything. The Memory port decides when experience moves: Recall
before every Think (into Prompt.Memories, replaced wholesale per round), Remember once per
invocation at its terminal (carrying CycleFacts). What is stored, how it is retrieved, and when
it is deleted stays yours (MemHop). The text track is unchanged: Organs.Context and
Hooks.BeforeThink still feed Prompt.Context.
Meowire is a flat model: one Agent = one kernel, and everything between two
agents stays with the host.
- Sub-agents: a
spawn_agenthost toolNews an instance, consumes itsStimulatestream and returns the result asEventToolResultfeedback — the kernel never learns a second instance exists - The kernel ships no inter-agent addressing, delivery, reply pairing, capability discovery or
synaptic graph:
Organshas no slot addressed to a neighbour andPrompthas no inbound track (test/wiring_free_test.goguards that boundary mechanically) - To connect agents, do it in the host: feed A's output into B's
Stimulate, or register B as a tool of A
GOWORK=off go test ./...
GOWORK=off go vet ./...| MeowAgent | github.com/meowagent/meowagent |
| MemHop | github.com/qyiun666/memhop |
| MeowDesk | github.com/qyiun666/MeowDesk |
| Website | qyiun666.github.io/meowagent.github.io |
| Host Integration Guide | host-integration.en.md |
| Thinker Adapter (openai-go) | thinker-openai-go.md — a real SDK adapter, field by field |
| Reference Host (zh-CN) | reference-host.md — step-by-step runnable AI host |
| Protocol Mapping Guide | protocols.md — MCP / A2A / AGENTS.md / Authority |
| qyiun666@163.com |