Learns from your corrections, on your machine — and rolls back a fine-tune that made it worse.
Runs on your Mac. Remembers what matters. Learns new skills. Fine-tunes itself with LoRA. No cloud inference. No subscription.
Website · Try the interactive demo · Quick Start · How it learns · Roadmap
A term coined for this project, because the thing being built is not on the road to AGI and is not trying to be.
AGI is one mind that is general across everyone. APGI is one mind that is general across everything YOU do — the same breadth of capability, narrowed to a single person, a single machine, and the particular way they work. It is not a smaller AGI. It is a different target, and most of what makes it hard is different too.
What it implies in practice, all of which this repository is an attempt at:
- The weights change for you, not for a population. A correction becomes a LoRA update on your machine, guarded by a battery that rolls it back if it made anything worse. Nobody else's install gets it.
- It learns an environment by acting in it. Given a world it has never seen it earns its own corpus: it acts, the world grades it by changing or not changing, and only what worked is kept. Measured here at 10/45 to 26/45, and six-step chains from 0/19 to 7/19, with no human writing a sample.
- It is one installation, not a service. No cloud inference, no shared fine-tune, no telemetry on by default. What it learns about you cannot leak into anyone else's copy, because there is no anyone else's copy.
- Personalisation is the general capability. The skills, the soul store, the constitution and the per-skill adapters exist to make it general across YOUR work rather than average across everyone's.
The honest limits are in this README too, measured rather than asserted: what it cannot bootstrap, where the harness was the thing in its way, and which numbers are one run rather than a result.
Most AI agents have a problem:
They forget.
like forget forget, get it? Cause it was in short term memory and you might shut down your computer?
You correct an agent today, and tomorrow it makes the same mistake again. You can put instructions in a system prompt, but that makes the prompt larger, it becomes slower when processing, and doesn't really teach the model anything.
Symbio takes a different approach - obviously why else make this?
You → Agent → Mistake → Correction
↓
Learning data
↓
LoRA training
↓
New adapter
↓
Agent improves
it now can be shown in the layer
Corrections and successful tool recoveries are automatically collected as training examples. Once enough examples accumulate, Symbio performs a small LoRA fine-tune and reloads the resulting adapter.
The goal is simple:
The longer you use Symbio, the more you define what AI means to you
Everything can stay on your machine, nothing phones "home"
- Learns from corrections — automatically detects corrections and turns them into training data.
- Self-corrects tool mistakes — successful recovery from a failed command can become a training example.
- Learnable skills — create a skill as a Markdown procedure and train a dedicated worker adapter for it.
- LoRA fine-tuning — only small adapter weights are trained; the base model stays frozen.
- Fine-tunes itself when it decides to — besides training automatically once enough examples pile up, the model can call its own
train_adaptertool mid-conversation. With default settings that runs without asking you; it is logged, and the golden set still rolls back a run that made things worse. - Watches its own weights change —
scripts/weight_delta.pyshows, module by module, how far each fine-tune moved the weights, and how much the latest run moved them compared with the one before. - Looks inside the model (experimental) — record which neurons fire on your own traffic, and grow new neurons that start at exactly zero change. See Looking inside the weights.
- Mixture of Agents — a headmaster can delegate bounded tasks to smaller worker models.
- Local memory — notes, sessions, training data, adapters and caches live locally.
- RAG retrieval — relevant notes can be retrieved and supplied as context.
- Web research — search the web and automatically save useful discoveries as notes.
- Browser automation — open pages, click, type and scroll through a live browser.
- Shell & Python tools — execute sandboxed commands and short Python programs.
- Telegram gateway — use your local Symbio instance from your phone.
- Permission gates — dangerous actions require explicit approval.
- Golden-set regression protection — bad fine-tunes can automatically roll back.
- Skill evaluation — compare base, prompted and adapter performance.
- Crash recovery — interrupted training is recorded and can be resumed.
- Self-pruning — junk notes and duplicate session turns can be archived.
- No API required for inference — the default architecture is designed around local models.
Try the real tag parser, correction miner, research memory and RAG retriever in your browser:
https://huggingface.co/spaces/HuyEdits/symbio-demo (no fine tuning just actions)
Symbio can use a live browser to perform tasks such as opening Chrome and interacting with pages. wowie
0731.1.mov
Symbio drives the Mac itself, not only a browser page. It reads the frontmost
window through the macOS accessibility API — the same interface a screen
reader uses — so see_screen comes back with the window's real controls:
Notes — window "Shopping"
1 Button 'New Note' at (48,96) 28x28
2 TextArea '(empty text field)' at (320,140) 600x420
3 Button 'Share' at (980,96) 28x28
Every action then takes a number: desktop_click {"element": 1},
desktop_type {"element": 2, "text": "..."}, plus desktop_press (chords
included: cmd+shift+4), desktop_scroll, desktop_drag, desktop_move,
desktop_wait and open_app. Nothing here loads a vision model, so the
listing costs no RAM beside the headmaster, it has no minimum control size,
and a control that is absent from the tree is genuinely not on screen.
Two things the tree gives that a screenshot cannot:
- Typing goes into a named field. Keys sent at a window with no text field
focused are not discarded, they are shortcuts.
desktop_typerefuses to type blind when the focused control cannot take text, and names the field to use instead. - A click that changed nothing says so. The window title and the focused control are read before and after, so "clicked" is not reported as "done".
Vision stays as the fallback for windows that draw their own interface — a canvas, a game, a screen share — where there is no tree to read.
Permission: macOS returns an empty tree rather than an error until the
terminal running Symbio is granted Accessibility. python3 -m symbio.ax shows
the grant dialog and then prints the live control listing; /selfcheck in
chat reports the same thing.
symbio-desktop # opens the chat window in your browserNo model has to be running first. Opening the window starts symb daemon
loading in the background, and a message sent before it is ready waits for it,
with the window counting the load out loud ("Waking Symbio — loading the
model… 5s"), then goes. Measured on the 14B from cold: about 7 s to load and
25 s to the first reply with a saved prompt cache. The same waker serves the
ACP and MCP bridges, symb tui, and symb chat (which now waits for a model
that is still loading instead of loading a second copy next to it); one lock
means two of them waking at once still start one model. A load that dies —
usually memory — is reported when it dies, with the end of daemon.log.
A chat-first interface: conversation in the middle, the agent's own tool calls folded into one line you can open, approval prompts inline (the same gate the terminal shows), and the adapter map, skills, corpus and health report behind a drawer you open when you want them.
It is deliberately not an Electron app. The server is the Python standard
library, the model lives in symb daemon and is reached over a Unix socket,
and the page is plain HTML and CSS — 28 MB resident idle, 31 MB after serving
every endpoint, against the 200-400 MB an Electron shell starts at.
--window opens a native WKWebView instead of a browser tab; that hosts WebKit
in this process and costs about 400 MB all told, which is why it is off by
default.
symb pet # put a cat on your desktop, watching this install
symb pet --demo # the same cat playing a scripted run; nothing is trained
symb pet status # is it out
symb pet stop # call it insymb pet starts it in the background, like symb daemon start; symb pet run
keeps it in the terminal instead, and symbio-pet does the same on its own.
A tilcayo — Leopardus tilcayo, the newest cat species, described on 17 September 2026: light-brown coat, big irregular rosettes, short round ears. It is the fine-tune, drawn:
- The cat is the base model. It is the same cat before and after every run.
- The charm on its collar is the LoRA adapter — a small thing hung on the model, not a change to it. A fresh run starts it from nothing (LoRA's B matrices start at zero) and it grows with every step: gold for the headmaster, cyan for a skill.
- Each fish treat is a couple of training steps, thrown at the pace the trainer reports them.
- The tail is the loss: lashing while the loss is high against where the run started, settling as it converges. The sparkline above it is the curve.
- The golden gate rules: the charm locks on with a sparkle when the adapter is kept, and the cat swats it off its collar when it is rolled back.
Between runs it sleeps while no model is loaded, thinks while the model works, and strolls along the top of the Dock now and then. Drag it anywhere, and right-click to stop it wandering. Double-click it and the chat opens as its own app — a native Symbio window with the cat in the Dock and the usual menus, not a browser tab.
It never imports symbio. Training writes logs/training_live.json from the
trainer's own lines and the gate's verdict; the pet reads that, the daemon's
pid and socket, and ps. Measured: about 80 MB resident, 4.5% of one core
sitting and 3% asleep, more while it walks or eats. It needs PyObjC
(pip install "symbio-cli[pet]") and runs on macOS only.
symb acp # an ACP agent on stdio, for Zed, the VS Code and
# JetBrains ACP plugins, Toad, and other ACP hosts
symb connect claude-desktop # Claude Desktop gets Symbio as tools (MCP)
symb connect hermes # so does Hermes Agent (CLI and desktop app)symb acp speaks the Agent Client Protocol:
the host starts it, and Symbio's resident model becomes the host's agent. The
reply streams in as the agent's message, Symbio's activity lines arrive as its
thinking, and its approval prompts are asked through the host's own permission
dialog. It starts symb daemon itself if no model is loaded. In Zed:
"agent_servers": { "Symbio": { "command": "symb", "args": ["acp"] } }The fine-tune loop comes along. A session has two modes: Private (the
default; nothing is added to the corpus) and Learn (the conversation is
saved for training when the session closes). The loop's commands — /save,
/learn, /train, /golden, /forget_last, /status — show up in the
host's command menu. A retrain started from the session appears as a tool call
that counts the steps and the loss, then reports the golden gate's verdict:
kept, or rolled back to the previous adapter. The four stages (collect, train,
golden gate, keep or roll back) are shown as the session's plan.
Claude Desktop hosts MCP servers rather than ACP agents, so symb connect claude-desktop registers symb mcp bridge in its config instead: Claude gets
ask_symbio (a turn with Symbio, which keeps the thread between calls) and
symbio_status (model, adapter, and any fine-tune in progress). Approval
prompts cannot be answered from inside a tool call there, so they are declined
and the reply says what was asked. --remove takes it out again.
Hermes Agent can run as an ACP agent but cannot host one, so it gets the same
MCP bridge. symb connect hermes registers it through Hermes's own hermes mcp add, which keeps the comments in its config.yaml. The loop's commands work
through ask_symbio too: send it /status, /save, /golden or /train and
their output comes back as the reply, without the trainer's per-step lines.
Hosts cut tool calls off (Claude Desktop after 60 seconds, Hermes after 300),
and a /train takes longer. So ask_symbio answers within 50 seconds (240 in
Hermes) with what it has so far, and says the turn is still running; the next
ask_symbio collects the rest instead of sending a new message, and
symbio_status follows the fine-tune.
Both bridges were checked with the official ACP and MCP SDK clients, and the MCP bridge by typing into Hermes Agent's own terminal UI.
symb daemon start # load the model now (the window and bridges also start it)
symb watch # keep it loaded, and run what is due
symb watch --status # the last heartbeatFlash attention with an 8-bit KV cache. agent.kv_bits: 8 stores the
cache at 8 bits, but mlx_lm then leaves its fused attention kernel for one that
builds the whole score matrix — attention without flash attention, which is
why mlx-lm#1587 finds kv_bits using more memory than it saves. llama.cpp (and
so Ollama and LM Studio) never has that trade: its kernels read the quantized
cache directly. Symbio now dequantizes one layer's K/V at a time for any
prefill and hands it to the fused kernel, keeping the quantized path for
single-token decode. On the 14B-3bit with a 6,000-token prompt: 60.3 s →
53.3 s to prefill and 2,595 MB → 1,132 MB of peak memory over the weights,
same answer. SYMBIO_NO_FLASH_ATTENTION=1 turns it off.
An adapter whose training went to nan is now refused when it is loaded, and the
base model answers instead: loaded, it made every reply a row of !.
Side models on the Neural Engine. symbio_ane/ane_helper.swift is a small
Swift program (built on first use with the command-line tools' swiftc, kept
running, ~108 MB) that does three jobs off the GPU the 14B generates on:
- OCR: Vision's text recognizer, pinned to the Neural Engine — ~120 ms for a
1080p screen once warm.
see_screenanswers questions about words from it and no longer wakes the 4 GB vision model (or sleeps the 14B) to read text; every VLM look also gets the exact text alongside. A blob with words in it is named by its words, not by a VLM generation. - Decision model: think first or answer now, from a nearest-neighbour vote over Apple's on-device contextual embedding (~7 ms). Held out, it made the think/no-think call right 30/31 against the regex's 19/31. Every decision is logged as a label. With Apple Intelligence turned on it asks Apple's on-device model to fill in a routing schema instead.
- Vision requests: saliency and scene labels on the Neural Engine
(
ane.vision), for callers that want candidate regions without a model.
ane.enabled: false turns it all off; every call falls back to the old path.
Replies in seconds, not half a minute. Measured on the 14B, same prompts, before → after: "hey, how's it going?" 18.3 s → 8.8 s; "what's 17 times 23?" 30.2 s → 4.5 s; follow-ups 4-5 s. Four changes:
agent.think_when: auto— plain conversation answers without a reasoning block; a task, code, a link or a tool round keepsthinking_level(alwaysrestores the old behaviour).- The standing context (curated memory, soul, env: ~840 tokens) moved out of the per-turn block into the cached prefix, snapshotted per session, and each past turn is replayed exactly as sent — a follow-up prefills ~250 tokens instead of ~880.
gpu.keep_model_wired(default on): the resident weights are wired, like LM Studio's "keep model in memory" or llama.cpp's--mlock. An idle daemon had 6.6 GB of its 8.7 GB in the compressor; every turn paid to page it back.- The soul reflection waits for 45 quiet seconds instead of running a 14B generation under your next message.
Scheduled jobs used to run inside a chat session's background thread, so
"every morning at 8" meant "every morning at 8, if a window happens to be
open". symb watch is the process that watches instead: it restarts the
resident model when it dies (with a backoff, because each attempt maps several
GB of weights), ticks the cron table itself, and hands each fired job to the
model as an ordinary turn.
Unattended turns are denied by default. The model asks its client before a
risky tool, and here the client is a loop — nobody is reading the prompt. The
answer is no, and the refusal is recorded in the heartbeat.
cron.unattended_approve turns that off for someone who has read this
paragraph.
There is no posting command. Symbio posts the way a person does: it opens the page, types into the box, clicks the button beside the box that sends it, and reads the page back. Nothing in the code names a site.
What makes that work on a 14B is what the page hands back:
- The page's boxes and buttons come with the page. After an open, or a
click that changes what is on screen, the observation lists the fields to
type into (with the selector that reaches them) and the buttons that send
them — send-like labels (Post, Reply, Send, Submit…) ahead of the
navigation,
[disabled]until the box has text. Before this the list only appeared after a step had failed, and the model typed at nothing and clicked the sidebar link that shares the send button's label. - Words the user didn't give are pointed out. If the user quoted “testing” and the model types "Hi", the result says so, beside the box.
- Sending is asked about where it happens. A click, a coordinate or cmd+enter that would send the text in a box — a post, a reply, a comment, a chat message, a form — is Post or send, on any site. The card shows the words in the box and warns when they aren't the ones you asked for. A search box's Go and a login form are not sends.
On 2026-09-27 the model, asked for “testing” on x.com, typed "Hi" into the composer and pressed Post; that route now stops at a card that says “You asked for “testing”, not this.”
What Symbio may do without asking is set per kind of action, in words you
would use — Post or send, Run commands and code, Change files, Use your
desktop, Browse the web, Forget things, Train itself, Change its
settings, Schedule work — each Allow, If risky, Ask or Never
(Settings → the first section in the window, or guardrails.modes in
config.json). The defaults are the old behaviour.
- One question per action, in plain English. The card's headline is the model's own sentence for the concrete call ("I'll post “Hi” publicly on your X account"); under it, the exact post or command, from the harness. If the words about to go out are not the ones you quoted, the card says so in red. It used to ask twice for one post, once with no text at all.
- A click is judged by what it lands on. A button that sends the text in a box — by text, selector, coordinates or cmd+enter — is Post or send on any site, whichever tool did it. Before this, browser_type plus browser_click posted "Hi" with nobody asked.
- Always allow on a card switches that kind to Allow; anything the risk scorer rates destructive still asks. Never is refused, and the model is told the refusal is yours.
- Floors no switch reaches: the security policy, self-destruction, and the model loosening its own guardrails.
- The window's server only talks to the window: requests must come from its own origin, so a web page open in your browser can no longer open the chat socket or write settings.
Symbio currently targets Apple Silicon Macs using Apple's MLX stack. (unfortunately until we can get support for CUDA, and other stuff)
- macOS
- Apple Silicon M-series Mac
- 16 GB+ unified memory
- Python 3.10+
- ~8 GB free disk space for the default setup
- Additional space for models, adapters and browser data
The default 8B-class (or others - check the wizard) configuration is much more comfortable with 16 GB+ RAM. Smaller models can be used on machines with less memory.
Hardware compatibility: Symbio is intended for Apple Silicon Macs. If you test it on different M-series generations or RAM configurations, please open an issue and share the model, RAM and configuration so compatibility can be documented properly.
Two ways, and you only need the repository for one of them.
pip install "symbio-cli[mlx]"
symb setup # names, model, first-run checks
symb chatNothing is written into your Python environment. Everything this install
keeps — notes, adapters, training data, sessions, config.json, prompt.md —
lives in a workspace at ~/.symbio, and SYMBIO_HOME points it somewhere
else if you would rather. Upgrading the package does not touch it; you can
delete the virtualenv and the agent still remembers everything.
The [mlx] extra is the inference and training engine. It is macOS/Apple
Silicon only and deliberately optional, because mlx publishes no sdist and a
hard dependency would make pip install fail during resolution on Linux
before anyone saw an explanation.
git clone https://github.com/huyedits/Symbio
cd Symbio
./install.shA checkout is its own workspace: run from a clone and the agent uses the clone, which is what you want while editing it. You need the repository for this and not for the above — the tests, the benchmark harness, the desktop app sources and the installer script are all development things.
The installer:
- Checks the machine and available resources.
- Creates an isolated virtual environment.
- Installs dependencies.
- Downloads the browser engine when enabled.
- Optionally prefetches the model.
- Drops you into an activated environment.
Exit the environment with:
exit or control + cYour original shell is untouched.
Then start Symbio:
symbioOr use the shorter command:
symbOn first launch, an interactive setup wizard asks for your name, Symbio's name, model preset and enabled features.
Re-run the setup wizard at any time:
symb setupShow installer options
./install.sh --prefetch-model # Download the model during installation
./install.sh --no-browser # Skip the Chromium download
./install.sh --with-native # Include experimental native extras
./install.sh --dev # Install development/test dependencies
./install.sh --no-shell # Install without entering the environment
./install.sh --venv PATH # Use a custom virtualenv locationThe core learning loop is intentionally simple:
1. You use Symbio
↓
2. Symbio makes a mistake
↓
3. You correct it
↓
4. Symbio detects the correction
↓
5. The mistake becomes training data
↓
6. Enough mistakes accumulate
↓
7. LoRA fine-tuning runs
↓
8. The adapter is loaded
↓
9. Symbio has learned from the examples
For example:
You: What is my name?
Symbio: Your name is Bob.
You: No, I'm Alice.
Symbio: Your name is Alice.
[Correction detected]
Saved mistake note
1/5 examples collected
Once the configured threshold is reached, Symbio digests the examples and runs a short LoRA update.
The threshold is not a constant. It scales with the corpus, because every
sample already in train.jsonl competes with the new ones: five boosted notes
are most of an epoch against 50 samples and a rounding error against 882. It
rises by log, not by multiple — a corpus ten times bigger needs a bit more
evidence, not ten times as much — and severe mistakes pull it back down.
Clamped to 2..20 either way; learn.mistake_threshold sets the base and
learn.scale_threshold_with_corpus turns the scaling off.
A correction is one of two things, and they want opposite recipes:
| What it is | What the fix needs | |
|---|---|---|
| Knowledge | the model said something false — a name, a version, which file holds what | passes over the corpus; a fact repeated four times in one epoch is still one fact |
| Reflex | the model reached for the wrong SHAPE — a tool that does not exist, a guessed argument, a GNU flag on BSD, a click before a look | repetition of the exact form; an action learned once is not automatic |
Every mistake note now records its **Kind:**, classified at capture time,
and the batch decides the dials: a batch of wrong shapes repeats harder and
runs shorter, a batch of wrong facts trains at ordinary weight for the full
run, and a mixed batch lands between. One batch is still one LoRA pass — this
machine trains one model at a time.
notes/mistakes/
↓
training_data/train.jsonl
↓
LoRA
↓
adapters/
↓
Symbio
The /learn command can still be used to manually trigger learning from the previous correction.
Symbio can also learn from its own successful recovery.
For example:
You: Open Chrome.
Symbio: <cmd>chrome</cmd>
Tool:
Command not found: chrome
Symbio:
'chrome' isn't a command here — trying the native way.
<cmd>open -a 'Google Chrome'</cmd>
[Learn]
Tool mistake captured.
The failed → successful sequence can become training data.
This means Symbio can learn not only from:
"No, that's wrong."
but also from:
"That command failed, so here's what actually worked."
Only a confirmed successful recovery is captured.
The same loop runs over the machine itself. A desktop is full of failures that come back looking like ordinary results, and each one used to end the turn:
| What came back | What it actually means |
|---|---|
No results found. |
the search produced no work |
Timed out after 30s. |
the command was killed |
Browser is not open. |
the action was aimed at nothing |
Refused to type: the focused control is a Button |
keys there are shortcuts, not text |
Nothing about the window changed |
the click may not have landed |
There is no element 9 on screen |
the listing is stale; look again |
The accessibility tree is empty |
a permission is missing, the screen is not |
Symbio treats all of these as failures rather than answers. Two things follow from that: the turn keeps going instead of reporting a dead end as done, and the recovery it finds becomes a training example the same way a corrected answer does.
You: post this to x.com
Symbio: <tool_call>{"name": "desktop_type", "arguments": {"text": "..."}}</tool_call>
Tool:
Refused to type: the focused control is a Button ('Post'), not a text field.
Look with see_screen target='desktop' and type into the field by its number.
Symbio: <tool_call>{"name": "see_screen", "arguments": {"target": "desktop"}}</tool_call>
<tool_call>{"name": "desktop_type", "arguments": {"element": 2, "text": "..."}}</tool_call>
[Learn]
Tool mistake captured.
Some of these were learned the hard way and are in the corpus as fixed
behaviour: a sed -i written GNU-style silently does nothing on macOS and got
reported as done; a tweet posted while the model said it had not; keystrokes
sent at an unfocused window that fired shortcuts instead of typing. The
environment is where an agent's confident wrong answers actually cost
something, so it is the environment that gets checked after every action.
Skills let Symbio turn procedures into dedicated, trainable capabilities.
Create one:
symb skill new "Fix wifi"Or from the chat:
/new-skill Fix wifi
A skill starts as a readable Markdown procedure:
notes/skills/fix_wifi.md
As the skill is used, mistakes and corrections are tracked separately:
notes/skills/fix_wifi.md
notes/skills/fix_wifi.md.health.jsonl
The Markdown file remains clean while the hidden health log collects training examples.
After enough examples accumulate, Symbio trains a dedicated worker adapter:
Skill
↓
Training examples
↓
LoRA
↓
adapters/workers/fix_wifi/
Each skill can therefore have its own adapter.
Adapters can be:
- loaded
- hot-swapped
- evaluated
- archived
- restored
Useful commands:
symb skill list
symb skill new "Fix wifi"
symb skill eval "Fix wifi"
symb skill rm fix_wifi
symb archive
symb archive --dry-run
symb archive --restore adapter fix_wifiThere is an obvious objection to learned skills:
"Couldn't you just put the procedure in the prompt?"
Symbio includes a three-way evaluation harness specifically to test this.
┌─────────────┐
│ Skill │
└──────┬──────┘
│
┌────────────┼────────────┐
↓ ↓ ↓
Base Prompted Adapter
│ │ │
no steps steps given no steps
│ │ │
└────────────┴────────────┘
↓
Compare
| Condition | Procedure in prompt? | Measures |
|---|---|---|
base |
No | What the base model already knows |
prompted |
Yes | The "just prompt it" baseline |
adapter |
No | What the LoRA adapter learned |
Run:
symb skill eval "Fix wifi"Or choose specific arms:
symb skill eval fix_wifi --threshold 0.7 --arms base,adapterExample:
Skill: Fix wifi
----------------------------------------------------------
condition steps in prompt score coverage
----------------------------------------------------------
base no 0/5 0%
prompted YES 5/5 93%
adapter no (in weights) 5/5 100%
----------------------------------------------------------
The adapter receives the worker's normal system prompt but not the procedure itself.
That makes the experiment much more interesting than simply checking whether the model can follow a prompt containing the answer.
By default, Symbio:
- generates multiple task phrasings
- deliberately avoids simply replaying training prompts
- compares step vocabulary
- strips enumerators such as
1.and2.from the metric - stores raw responses in the JSON report
- reports null results rather than inventing a score
The goal is to make the evaluation auditable rather than flattering.
A high adapter score demonstrates recall of the trained procedure. It does not prove general intelligence or deep conceptual understanding.
Measured on 2026-09-15, Qwen3-14B on an M-series Mac, against a
deliberately counter-familiar cloud CLI built so that pretrained knowledge
actively misleads (store/compute/access, --region required on every
command, no URI scheme, Key:Value labels, tag/Key:Value filters). The
system prompt says nothing about the syntax. The base model scores 0/13.
Nothing in the corpus is hand-written. The model attempts a task, the simulator runs what it emits, and the pair is kept only if the world changed the way the task asked. A model that never gets a command right earns exactly zero samples.
| iteration | 0 | 20 | 40 | 60 | 80 | 100 | 140 | 160–1000 |
|---|---|---|---|---|---|---|---|---|
| score | 0/13 | 3/13 | 9/13 | 11/13 | 12/13 | 13/13 | 12/13 | 13/13 ×43 |
Perfect score at iteration 100 of 1000. The other 900 iterations — 90% of the compute, 23 of the 25.7 minutes — changed nothing. Training cost 1.5s per iteration at 8.4 GB peak.
A later run went against 19 held-out tasks rather than 13 — the six extra are chained, multi-command ones. Same model, same self-earned loop (481 attempts, 82 kept: a sample is earned only when the simulator's world changed the way the task asked, so 17% of tries paid). 14 checkpoints, every 20 iterations:
| iter | 0 | 20 | 40 | 60 | 80 | 100 | 120 | 140 | 160 | 180 | 200 | 220 | 240 | 260 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| score | 0 | 6 | 6 | 7 | 13 | 12 | 11 | 15 | 8 | 13 | 16 | 13 | 13 | 16 |
Peak 16/19 at iteration 200, and the curve is not monotone: 15 at 140, then 8 at 160, then 16 at 200. A single checkpoint is not a measurement here. The 13/13 above is real and reproducible on the 13-verb battery; it does not survive being restated as "the model learned the CLI".
Reading what the best checkpoint actually emitted, rather than its score:
| task | emitted at iter 200 | exit |
|---|---|---|
filter_nodes |
compute list-nodes --region eu-2 --where tag/Env:prodd |
0 |
new_identity |
access new-identity --region eu-2 --identity-name deployment-bot |
1 |
chain_three_step |
three commands, last one --to-container nightly --to-path run.log |
0 |
Two of the three succeed at the command line and still fail the task. The
grammar is learned: --where tag/<Key>:<Value> is exactly right, and prodd
is a doubled character in the value. chain_three_step emits all three
commands cleanly and copies into the wrong container — the task names two
(nightly, archive) and the second one is dropped.
new_identity looks like the one honest option error, and it is not.
--identity-name is --identity with -name appended, and the environment
answers it with Options for 'new-identity': --identity, --region every one of
the eight times it appears. On the two checkpoints where the option is right
(80, 120) the run still fails, because the value comes out as
name:deployment-bot — the option's own name, glued to the front of its
argument. It passed exactly once, at iteration 140.
filter_nodes never passed once in 14 checkpoints, and what it emitted
says why. The value it needs is prod. Across the nine checkpoints from 100
on, it wrote prodd five times, producers three times, and once
proddumps/db.dump — every one of them prod followed by characters that
should not be there. It is not guessing the wrong value; it cannot stop
emitting the right one. Eight of those nine exit 0 with correct grammar; the
ninth is iteration 160, where the value ran on into an unrelated path and broke
the syntax as well — the same checkpoint where the whole score collapsed to
8/19.
Put beside each other, the residual failures are one failure. prod becomes
prodd. --identity becomes --identity-name. deployment-bot becomes
name:deployment-bot. In every case the correct token is emitted and then not
stopped — this is not a model that has learned the wrong thing, it is a model
that cannot end a string. Same shape as the emitter bug that lost a 15-round
crypto task to a variable whose name had a character dropped: the reasoning was
right and the emission was not.
That matters for what to do next, because it is the one failure more training
data cannot reach. The flat 13/13 ×43 tail above and this run's wobble are
both saying the same thing from opposite ends — the knowledge arrived early,
and the iterations after it are spent on something that is not knowledge.
Sampling (repetition_penalty, the stop conditions) and lora.scale are where
this lives, and none of it is measured yet.
Three runs, same model, same loop. Only the environment changed:
| errors the environment gives | tasks solved | verbs earned | peak score |
|---|---|---|---|
| the current service's verbs | 39/60 | 10 | 10/13 |
| every service's verbs | 59/60 | 11 | — |
| + full task coverage | 70/72 | 13 | 13/13 |
It passed exactly the verbs it had earned samples for and generalised to none it had not. Earned 10 → scored 10. Earned 13 → scored 13.
Why the error text mattered so much: asked to create a container, the model
tried awsim create-container, was told the three services, guessed compute,
and was then told compute's four verbs — none of which make containers. It
never tried store. Every message was locally accurate and kept it inside the
wrong service. Returning the whole verb map on any unknown service or verb
took discovery from 39/60 to 59/60 and cut the time by 40%.
Rigid means refusing wrong input, not rationing information. A model that is lost gets the whole map.
60 training examples (plus 10 held out) covered 13 verbs — roughly 2–6 each:
| verb | examples | result |
|---|---|---|
compute resume |
2 | passed |
compute halt |
3 | passed |
store duplicate |
3 | passed |
Those numbers tempted an obvious rule — "about three examples per capability" — and the rule did not survive being tested forward. Over-sampling two starved shapes to feed them deliberately, 1 of 4 predictions held: two chained shapes passed on a SINGLE example each, and one failed with thirteen. Reading what it emitted showed the thirteen-example failure had learned the verb perfectly and misread an ambiguous task (the target path began with another container's name).
What the rule really was: a description fitted after the fact, which held only because the under-sampled shapes were also the hardest ones — sample count and difficulty moved together across three runs and separated the moment they were pulled apart.
The better hypothesis, and it is untested: compositionality. "Shut it down and label it" is two verbs it already knows, sequenced — one example teaches the sequencing. "Get rid of the container, it still holds a path" needs a precondition nobody stated, and that is genuinely new. Treat any per-capability sample figure here as unestablished.
The first version of this benchmark mimicked the real AWS CLI, and the
model scored 13/13 before a single step of training. It was reciting aws s3 mb from pretraining. A benchmark a model already knows measures recall and
reports it as learning.
And when the curve first read 0/13 at every checkpoint while the training loss
said 0.055, that was the evaluation, not the model: mlx_lm lora has no
--keys flag and trains q/k/v/o plus all three MLP projections (112 tensors at
8 layers), so an eval that attaches only q_proj/v_proj loads 32 and
load_weights(strict=False) drops the other 80 in silence. A silent partial
load is indistinguishable from a model that learned nothing. The harness now
reads the training run's own adapter_config.json and refuses to score a
checkpoint whose tensors have nowhere to land.
The harness that produced all of this is not shipped. It was a research rig — a fake cloud CLI, a self-teaching loop and a checkpoint scorer — that nothing in Symbio imports and that no install can use. What it measured is worth keeping and is written above; the rig itself was one machine's scaffolding, and the repository is lighter without it.
Measured 2026-09-17/18, Qwen3-14B-3bit on a 16 GB Mac, against a reef-keeping
simulator: 50 verbs across 5 services, reef <service> <verb> --option value, deliberately counter-familiar — a running organism is a graft, you
do not create or delete anything, and every command needs --basin. 45
tasks, 19 of them six steps deep. The six-step shape is seal, settle, lift,
bind, open, prime, and every step fails loudly if the one before it did not
happen, so it cannot be brute-forced by emitting six plausible lines.
Nothing in the corpus is written by hand. The model gets the task in English, emits commands, the simulator RUNS them, and the pair is kept only if the basin ends in the state the task described. The system prompt says what the world IS and nothing about how to spell a command.
A loop that trains itself needs a way to notice that it has got worse, because the loss will not tell it. Nine things do, and every one of them is a refusal the run can lose to rather than a warning it prints.
Before the run. The corpus is checked, not trusted: samples with broken
tool calls are dropped (drop_broken_tool_call_samples), unparseable rows are
removed and counted, prompt-masking is verified against the model's own chat
template, and lora.keys is checked against the module tree — a key that
matches nothing produces a clean log and an adapter that learned nothing, so
it is caught before the trainer starts rather than after. A memory preflight
refuses a run the machine cannot hold, because an accepted one is not a failed
run, it is a Jetsam kill.
The baseline. golden.run_golden_set scores a fixed battery — identity,
tool-tag formatting, the contracts that must not break — against the CURRENT
model, before anything is trained. Without a before, "it still passes" is not
evidence.
During the run. The early-stop monitor reads each validation loss and
keeps the best: val_loss=0.0540 best=0.0420 patience=1/2. At patience it
kills the trainer and restores the best checkpoint — one run trained 120
iterations and kept 80. A validation loss that is implausible (nan, or a
number no real run produces) aborts instead of being recorded as an
improvement.
After the run. The battery runs again. A case that passed before and fails
now is a regression, and golden_rollback_on_regression restores the backup
backup_adapter took — the fine-tune is discarded, not shipped. Before that
it will try once more with extra iterations and remedy samples for the
specific failing cases (append_golden_remedy_samples), because a rollback
that gives up on a fixable regression just preserves it. A wildcard check then
asks about subjects absent from the corpus, which is where a narrow fine-tune
shows up as confident nonsense.
What the loss cannot say. weight_delta_report compares the new adapter
against the pre-train backup, per tensor and overall:
[Train] Weight delta vs the pre-train adapter: 8 tensor(s), 0.26M parameters,
overall movement 4.73%.
[Train] Moved most: self_attn.lora_a 10.0%.
[Train] 7 of 8 tensor(s) did not move at all — a target the run never reached
is the usual cause, and it is invisible in the loss.
A run whose LoRA targets matched nothing produces a respectable curve and an adapter identical to the one before it. Movement is the only thing that distinguishes the two, and "no previous adapter" and "the shape changed" are reported as unknown rather than as zero — unknown and unchanged are the two answers that must never be confused.
Around all of it. The adapter is sealed and verified at load time against a Merkle root, so weights that changed since they were sealed are refused rather than loaded quietly. In-flight runs are journalled with a pid and a boot id, so an OOM kill leaves a resumable record instead of a half-written adapter that loads as the real one. Worker adapters get the same treatment against their own golden sets, one role at a time.
The loop closes on itself. A task is put to the model in English; the model emits commands; the simulator runs them; the pair is kept only if the world ended in the state the task described; the kept commands are reduced to the ones that carried it; the corpus is rendered, trained, and the same battery is scored again with the new adapter attached. Nobody writes a sample, nobody labels one, and nobody decides whether an answer was good — the environment decides by being changed or not being changed.
That is what the four rounds below measure: 10/45 to 26/45, and six-step tasks from 0/19 to 7/19, on a corpus the model earned by acting. Five of the seven six-step solves were tasks whose answers were never in its training data, and one task it was trained on it still failed — which is what generalisation looks like and what memorisation does not.
The honest boundary: a person still builds the environment and starts the runs, and three fixes to the harness between rounds mattered more than any single round of training. What no person does is supply an example of the work. Once the environment exists, the data does not come from a human, and neither does the grade.
| trained on | solved | depth 6 | |
|---|---|---|---|
| 1. base model | — | 10/45 | 0/19 |
| 2. first adapter | its own 10 samples (depths 1-4) | 16/45 | 0/19 |
| 3. more runway | same 10 samples, 12 rounds per task | 19/45 | 3/19 |
| 4. chains in the corpus | 19 samples including 3 six-step | 26/45 | 7/19 |
| 5. ten tasks held out | 21 samples, none from the held-out set | 21/45 | 5/19 |
Round 4 scores higher and is the weaker number: five of the tasks it was scored on had their own answers in its training data. Round 5 is the honest one — ten tasks were reserved before training, chosen by position so nothing about how hard they turned out could decide which side of the line they fell.
Scored against those same ten in every round:
| round | all 45 | held-out 10 | held-out six-step |
|---|---|---|---|
| 1. base | 10/45 | 2/10 | 0/4 |
| 2. first adapter | 16/45 | 3/10 | 0/4 |
| 3. runway + nudges | 19/45 | 2/10 | 0/4 |
| 4. chains (leaky) | 26/45 | 5/10 | 0/4 |
| 5. held-out clean | 21/45 | 4/10 | 1/4 |
On tasks that were never in any corpus, the base model solves 2 and the
self-taught one solves 4. Its first six-step chain on a reserved task —
transplant_2, seal, settle, lift, bind, open, prime — lands in round 5, on
an adapter that had never seen any of the four.
Train split 17/35 against held-out 4/10: 49% and 40%. A model that had memorised its corpus would show that gap much wider, and this is the number that says the six-step result is a learned shape rather than a recalled one.
Ten reserved tasks and four reserved chains is a small sample, and every cell above is one run. Read the direction, not the decimals.
Round 1 said the long chain was not reachable from a corpus containing no example of one. Rounds 3 and 4 are what it took to test that, and both halves mattered: runway and two corrections got the first three six-step solves out of the model, and training on those three took it to seven. Nothing else changed between rounds 3 and 4 — same tasks, same twelve rounds, same prompts. The corpus is the variable.
Round 4's training run is also the clearest picture of the loop adjusting
itself: validation went 2.362 at iteration 1 down to 0.042 at 80, rose to
0.054, came back to 0.042, and the early-stop monitor — patience=2/2 — kept
iteration 80 out of a planned 120 and threw the rest away.
Runway alone did nothing. Watched live at twelve rounds, the model converged on
a set of commands the basin ACCEPTED and that did not accomplish the task, then
re-sent the identical eight lines four rounds running — 8 command(s), 6 accepted, world not there yet, four times over. Nothing it sent was an error,
so no error text could reach it, and it never once read the basin back.
Two things fixed that, and neither is the answer to any task: that a repeat is
a repeat (the rule the assistant's own persistence ladder runs on), and that
the world can be READ — graft show, host show, graft list. It had those
verbs from the first round and used them zero times; it has used them 42 times
since. Depth 4 went 0/4 to 2/4 on that alone.
The first six-step chain it ever solved was recorded as twenty-five commands: the working sequence, four redundant re-brews of the same culture, four re-opens of the same channel, and the six inspection calls it made along the way. Every one was accepted, so every one went into the sample — and a corpus like that teaches a six-step task in twenty-five moves.
Samples are reduced now the way a minimal reproduction is: drop a command, replay from a fresh seed, keep the drop if the task still passes. Twenty-five became six.
reef channel seal --graft g3
reef graft lift --graft g3
reef graft bind --graft g3 --host reef-s
reef channel open --graft g3 --culture algae
reef channel flow --graft g3 --rate 1
reef graft prime --graft g3
Fifty-five redundant commands came out of the three corpora this way.
A task a do-nothing answer passed. "Quarantine coral-a and then release it
again" ends in the state it starts in. The driver logged world SOLVED for two
commands the simulator had rejected, and wrote a training sample whose answer
was empty. The harness now validates both ways before it grades anything —
reference answers 45/45, do-nothing answers 0/45 — and refuses any
sample where not one command was accepted.
The harness rationed what the environment was handing over. The simulator
answers an unknown verb with every verb it has, which is the finding the
previous environment paid for: discovery went 39/60 to 59/60 when the errors
stopped being helpful-but-narrow. The driver then clipped that error to 200
characters before feeding it back, cutting the list off exactly where culture: brew would have been. The model hunted for "create" for four rounds while the
answer sat in a string the harness had already thrown away.
Vocabulary is not grammar. With the full verb list restored it still wrote
reef culture plankton brew --potency 4 — noun where the verb goes — four
rounds running. The list told it which words exist and nothing about where they
go. One line added to the error text:
The order is
reef <service> <verb> --option value— service first, verb second, and every value passed as --option value. Example:reef culture brew --culture plankton --potency 4 --basin tide-1.
1 solve in 27 became 6 in 8. Same model, same tasks, same four rounds. Rigid means refusing wrong input, not rationing information — and that applies to the harness at least as much as to the environment it is testing.
Symbio can optionally use a Mixture of Agents (MoA) architecture.
Instead of asking one large model to perform every task, a headmaster model can delegate bounded tasks to smaller worker models.
┌──────────────┐
│ Headmaster │
└──────┬───────┘
│
delegate bounded task
│
┌──────────────────┼──────────────────┐
↓ ↓ ↓
Summarizer Browser Custom
Worker Worker Worker
│ │ │
└──────────────────┴──────────────────┘
↓
Result → Headmaster
Delegation is disabled by default:
{
"dispatch": {
"enabled": false
}
}This is intentional because loading multiple models increases memory usage.
| Worker | Purpose |
|---|---|
summarize |
Condense text supplied by the headmaster |
browser |
Choose bounded browser actions from the current page |
Workers are loaded lazily and can be unloaded when idle.
Each worker can also have its own training corpus and LoRA adapter:
training_data/workers/<role>/
adapters/workers/<role>/
This means the browser worker can learn browser behavior without modifying the headmaster.
Delegated tasks automatically generate (input, output) training examples.
Worker training uses the same safety mechanisms as headmaster training:
- golden-set evaluation
- regression detection
- automatic rollback
- separate adapters
- memory preflight
- crash recovery
Self-training is useful, but blindly training on everything an agent produces is dangerous.
Symbio therefore has several safeguards.
Before and after each LoRA update, Symbio runs a fixed golden set.
The set checks behaviors such as:
- identifying itself correctly
- distinguishing itself from the user
- producing expected tool formats
- avoiding repetitive output
If a new adapter causes previously passing cases to fail:
[Golden] Regression: 2 case(s) newly failing.
[Golden] Rolled back to the previous adapter.
The previous adapter is restored automatically.
Run manually:
/goldenor:
symb eval-loraA self-learning agent has an unusual failure mode:
bad output
↓
saved
↓
retrieved
↓
repeated
↓
trained
↓
bad output becomes stronger
Symbio tries to break this loop.
Retrieval excludes internal machinery such as:
- tool transcripts
- tool-call syntax
- system observation scaffolding
- other generated machinery
Retrieval also requires meaningful, relatively rare terms rather than simply returning the least-bad matches.
If nothing relevant matches, retrieval is allowed to return nothing.
Junk notes and duplicate session turns can be archived automatically.
symb archiveOr preview:
symb archive --dry-runThe /tidy command performs additional cleanup:
/tidy
/tidy dry
Notes are archived rather than silently deleted.
Long-running training jobs are recorded before they begin.
If the process crashes:
[Resume] training for worker 'fix_wifi' was interrupted.
[Resume] 1 unfinished task(s) carried over.
Check pending work:
/resumeRun it:
/resume runDiscard it:
/resume clearTraining is not automatically restarted after a crash. This prevents a machine from repeatedly entering an out-of-memory cycle.
Training starts two ways. The automatic way: corrections and recovered
mistakes collect as examples, and once enough accumulate a LoRA run starts on
its own. The other way: the model itself can call train_adapter (keep
training on what it has) or retrain_adapter (rebuild from scratch) whenever
it judges a fine-tune is due. At the default safety.require_confirm_score
of 3 these score 2/3, so they run without a prompt and land in the security
log. Raise the bar to 2 if you want to approve every self-started run.
Either way the golden set still grades the result and rolls it back if it
got worse.
LoRA never rewrites the base model. Each module it touches becomes
W + scale · (lora_a @ lora_b), so the change is readable straight from the
adapter file:
venv/bin/python scripts/weight_delta.py adapters/ --base mlx-community/Qwen3-14B-3bitmodule |dW| rel
model.layers.38.self_attn.q_proj 11.616 10.181% ########################
model.layers.38.self_attn.v_proj 6.225 9.342% ############
model.layers.39.self_attn.q_proj 10.995 9.757% ######################
model.layers.39.self_attn.v_proj 5.969 9.635% ############
That is the live 14B adapter on one machine: four attention modules in the
last two layers, each moved by about a tenth of its own size. --since OLD
compares two adapters (two checkpoints of one run, or before and after a
retrain) and prints how much the latest training moved each module.
bench/activation_recorder.py hooks every layer and MLP of the headmaster,
records which neurons fire on one set of prompts, then tests on a second set
whether that recording means anything, by pruning with it and with a random
choice. On Qwen3-14B-3bit, measured as how often the pruned model still picks
the full model's next token:
| cut | chosen by the recording | chosen at random |
|---|---|---|
| switch off 10% of neurons | 0.875 | 0.776 |
| switch off 50% of neurons | 0.584 | 0.200 |
| drop 8 of 40 layers | 0.478 | 0.484 |
Neuron firing is a real signal. Layer "influence" is not; it did no better than random.
bench/neuron_adapter.py adds weights instead: new MLP neurons beside the
frozen base, whose output starts at exactly zero, so at step 0 the model is
bit-for-bit unchanged. On Qwen3-0.6B, four examples taught it a fact it could
not have known, and it answered a phrasing it never saw. Left switched on for
everything, the new neurons also bent unrelated answers (the capital of
Australia turned into Vancouver). Switched on only for their own skill, the
same way skill adapters are triggered, unrelated replies were identical to
the base model's. Neither of these is wired into the agent yet.
Symbio can interact with the local machine through several tool groups.
Each tool is one markdown file in tools/, seeded on first run and yours to
edit afterwards:
---
name: browser_click
family: browser
group: browser
---
Click an element in the open browser, identified by its visible text.
```json
{"type":"object","properties":{"target":{"type":"string"}},"required":["target"]}
The system prompt does not carry all of those schemas. It carries the *index* —
the families, the tool names in each, one line on what the family is for — and
the model asks for the arguments it needs with `tool_docs`. That is about 1,300
tokens instead of 5,100, on every turn, which on a 14B is room to think in.
`/tools` shows the same index you are showing the model; `/tools browser` prints
the schemas. `agent.tool_catalog: "full"` puts every schema back inline.
### Files
Read, write, search and patch files within the project environment.
### Terminal
Run sandboxed shell commands:
```xml
<tool_call>
{"name":"terminal","arguments":{"cmd":"ls -la"}}
</tool_call>
Execute short Python programs through the controlled execution environment.
Interact with a live browser:
- open
- click
- type
- scroll
- inspect page content
Save information for future retrieval:
<tool_call>
{"name":"note","arguments":{
"action":"add",
"target":"note",
"content":"The user likes coffee."
}}
</tool_call>Search the web and save useful discoveries as local Learned: notes.
obs_record starts, stops or checks an OBS Studio recording, so a task you
are filming can end itself: Symbio finishes the job, then stops the
recording, and the clip ends on the result instead of on you reaching for
the mouse.
It talks to OBS's built-in WebSocket server (OBS 28 and later), not a hotkey
or a click on the OBS window, so nothing is pulled in front of what is being
recorded, and every answer is OBS's own report: stop names the file OBS
saved, and only says STOPPED once OBS confirms no recording is running.
One-time setup: in OBS, Tools → WebSocket Server Settings → Enable
WebSocket server. The port and password are read from OBS's own settings on
the same Mac; set obs.host, obs.port or obs.password in config.json
(or SYMBIO_OBS_PASSWORD) to point it elsewhere. Starting a recording is
scored as a screen capture, so on a turn where you did not ask for it, it
asks first.
You: Submit the Show HN post, then stop the recording.
... browser steps, the Allow card for the post ...
[Submit CONFIRMED ... /item?id=...]
[Recording STOPPED] Saved to ~/Movies/2026-09-25 14-02-11.mov
Run the same agent through a Telegram gateway.
Start the gateway:
symb gateway startCheck readiness:
symb gateway statusStop it:
symb gateway stopSet the bot token through the setup wizard or environment:
export SYMBIO_TELEGRAM_TOKEN="..."You must explicitly configure allowed chat IDs:
symb config set telegram.allowed_chat_ids '[123456789]'Telegram dangerous actions use inline approval.
For example, actions involving:
- shell commands
- browser domains
- Python execution
- configuration changes
- scheduled jobs
- training
can require an explicit approval before execution.
Important: saying
Norejects the action for the entire turn. Symbio will not retry the same action through another tool.
The longer you use it, the more it works out how you want to be worked with — and writes it down where you can see it and change it:
$ /constitution
Held — this is in every prompt:
answers_vs_control: answers Give the result first. Do not narrate the steps
or offer a menu of options unless they ask for
one. (4 for/0 against, since 2026-09-10)
from: told me twice to just do it; asked for the number only
act_vs_confirm: act Take the ordinary reversible step without
asking. (yours, since 2026-09-14)
It holds one stance per question, not a pile of observations. That is the
whole design: soul.md collects what it saw each turn and only appends, so it
ends up holding "wants to approve everything" and "wants no confirmations" at
the same time, forever. An axis can only hold one, so new evidence either
reinforces the stance or argues against it, and an axis flips only once the
other side outweighs it — dated, so you can see when you changed.
The questions it holds a stance on (/constitution axes): answers vs control,
act vs confirm, brief vs complete, speed vs caution, do vs teach, blunt vs
cushioned, show vs summarize, code vs prose.
/constitution set <axis> <pole>— your own word. Inference never overwrites it; it can only record that the evidence disagrees./constitution clear <axis>— drop it./constitution revise— fold in what has been observed since last time.- It is
constitution.md. Edit it by hand if you'd rather.
Everything inferred goes back to the model wrapped as untrusted data, like every other store derived from conversation — so a web page cannot install a preference by being read, written down, and read back as yours.
When a turn keeps failing, the harness does not repeat "try something else". It escalates, and what it attacks moves inward:
1 the call use the error you just got
2 the approach that is the same attempt; here is what you have not tried
3 the assumptions name what this rests on and test the weakest one
4 the evidence you may be wrong about what you SAW; go and re-read it
5 the method solve it as if that tool didn't exist
6 the problem you may be solving the wrong problem
A repeated call is refused without running — a repeat is not another attempt — and the refusal comes back as the next rung. The ladder never runs out: what ends a turn is the round budget, which counts work actually attempted, never the harness running out of things to say. Every rung still leaves an honest way out ("here is what blocked me"), because pressure with no acceptable answer but success is pressure to fabricate one.
Tune with agent.min_distinct_attempts and agent.max_persistence_challenges.
A command is a file in commands/ whose body is a prompt:
---
name: standup
description: What moved and what is blocked
---
Read my notes from the last two days$ARGUMENTS, then give me three lines:
what moved, what is blocked, what I should start with today.Type /standup and that body becomes your next message, with $ARGUMENTS
replaced by whatever you typed after the name ($1, $2, … take the words).
/on its own prints every command, yours first/then Tab completes against them/commands new <name> | [description] | <prompt>saves one without leaving the chat/commands show <name>,/commands rm <name>- a mistyped command suggests the nearest real one
The assistant can write one too, with the save_command tool, when it notices
you asking for the same shape of thing repeatedly. Commands it wrote are marked
in the listing — and saving one never runs it; only you do, by typing the name.
| Command | Description |
|---|---|
/start |
Welcome message |
/help |
Show available commands |
/ping |
Show latency breakdown |
/status |
Show model, adapter and session status |
/golden |
Run the golden set |
/train |
Start LoRA training |
/selfcheck |
Check enabled features |
/setup |
Configuration help |
/tools |
Toggle tool groups |
/cancel |
Clear the current session |
symbio # start chat
symbio config # Show configuration
symbio config get <key> # Read a config value
symbio config set <key> <value>
symbio train # Run LoRA training
symbio skill list # List skills
symbio skill new <name> # Create a skill
symbio skill rm <role> # Delete a skill
symbio skill eval <name> # Evaluate a skill
symbio eval-lora # Evaluate headmaster adapter
symbio archive # Archive idle data
symbio archive --dry-run # Preview archive actions
symbio gateway status # Check Telegram
symbio gateway start # Start Telegram
symbio gateway stop # Stop TelegramInteractive chat uses a compact, Hermes / Claude Code-inspired layout: a warm welcome panel with the model and workspace, a clearly separated input line, and grouped tool calls/results. It stays in normal terminal scrollback rather than taking over the screen, so copying output and scrolling still work.
- Enter sends; Tab completes slash commands in both local and daemon chat.
/opens the full command menu;/statusshows detailed session state./quitexits. Existing readline editing and history keys are unchanged.- Narrow windows get a smaller layout;
NO_COLOR=1orTERM=dumbretains the plain-text interface. Piped output and non-terminal front ends stay plain.
Preview the layout without loading a model, reading credentials, or running tools:
python symbio/app/chat_style.py --demo
python symbio/app/chat_style.py --demo --width 48 --no-colorRestart a running resident daemon with symbio daemon stop followed by
symbio daemon start to pick up the new welcome-panel metadata.
Once inside Symbio:
| Command | Description |
|---|---|
/quit |
Exit |
/save |
Save the current conversation |
/train |
Run LoRA training |
/train_worker <role> |
Train a worker |
/resume |
Show unfinished work |
/learn |
Learn from the last correction |
/digest |
Convert notes into training data |
/note [title] |
Create a note |
/notes |
List notes |
/new-skill <name> |
Create a skill |
/skills |
List skills |
/skill-adapters |
List skill adapters |
/archive |
Archive idle notes/adapters |
/restore note|adapter <name> |
Restore archived data |
/status |
Show current state |
/selfcheck |
Run health checks |
/setup |
Re-run setup |
/compact |
Compress memory |
/model |
List model presets |
/model <preset> |
Switch model |
/run <cmd> |
Run a sandboxed command |
/forget_last |
Remove the last exchange |
/prune |
Remove stale adapter checkpoints |
/tidy |
Clean junk notes and duplicate turns |
Symbio uses LoRA (Low-Rank Adaptation) through Apple's MLX ecosystem.
The base model remains frozen.
Only small adapter matrices are trained:
Base model
████████████████████████████
+
Small LoRA adapter
↓
Personalized model
Adapters are stored separately:
adapters/
adapters/workers/
This allows Symbio to:
- train incrementally
- keep the base model unchanged
- switch adapters
- archive unused adapters
- roll back failed updates
- maintain separate adapters for different skills
Run training manually:
symb train| Setting | Default |
|---|---|
lora.rank |
8 |
lora.num_layers |
8 |
lora.scale |
20.0 |
lora.dropout |
0.0 |
lora.learning_rate |
1e-4 |
lora.iters |
300 |
lora.max_seq_length |
512 |
lora.save_every |
100 |
Training uses validation checks and can stop early when validation loss plateaus.
#Configuration
Configuration lives in:
config.json
You can edit it directly or use the CLI:
symb config
symb config get agent.temperature
symb config set agent.temperature 0.7Some important settings:
| Key | Default | Purpose |
|---|---|---|
model_name |
Qwen/Qwen3-14B |
Base model |
assistant_name |
Symbio |
Assistant name |
agent.temperature |
0.7 |
Generation temperature |
agent.max_tool_rounds |
3 |
Tool rounds per turn |
agent.max_reply_tokens |
128 |
Maximum reply length |
agent.prompt_cache_enabled |
true |
Reuse prompt KV cache |
lora.rank |
8 |
LoRA rank |
lora.iters |
300 |
Full training iterations |
learn.enabled |
true |
Enable learning |
learn.auto |
true |
Detect corrections automatically |
learn.auto_train |
true |
Automatically train at threshold |
learn.mistake_threshold |
5 |
Corrections before auto-training |
learn.batch_train_iters |
25 |
Auto-training iterations |
learn.boost_factor |
3 |
Correction sample weighting |
dispatch.enabled |
false |
Enable worker delegation |
dispatch.max_resident_workers |
1 |
Workers kept in memory |
telegram.allowed_chat_ids |
[] |
Authorized Telegram chats |
Symbio is designed to run locally, but local does not mean automatically safe.
Shell and Python execution run with the privileges of the user who launched Symbio.
The sandbox is intended to reduce accidental damage, not provide a perfect security boundary.
The environment variable:
SYMBIO_TELEGRAM_TOKEN
etctakes precedence over the token stored in config.json.
The project is organized as a Python package with a thin compatibility wrapper:
.
├── main.py
├── symbio/
│ ├── constants.py
│ ├── app/
│ │ ├── cli.py
│ │ ├── chat.py
│ │ ├── config.py
│ │ ├── training.py
│ │ ├── learn.py
│ │ ├── golden.py
│ │ ├── eval.py
│ │ ├── skill_eval.py
│ │ ├── prune.py
│ │ ├── pending.py
│ │ ├── dispatch.py
│ │ ├── memory.py
│ │ ├── sandbox.py
│ │ ├── computer.py
│ │ ├── cron.py
│ │ ├── telegram.py
│ │ ├── tooling.py
│ │ ├── prompts.py
│ │ └── skills.py
│ └── utils.py
├── rag.py
├── models.json
├── config.json
├── notes/
├── training_data/
├── adapters/
├── cache/
├── logs/
├── sessions/
├── screenshots/
└── sandbox/
| Component | Responsibility |
|---|---|
chat.py |
Agent loop and sessions |
training.py |
LoRA training |
learn.py |
Correction detection |
golden.py |
Regression protection |
skill_eval.py |
Skill evaluation |
dispatch.py |
Mixture-of-Agents workers |
memory.py |
Notes and persistent memory |
sandbox.py |
Shell/Python execution |
computer.py |
Browser automation |
telegram.py |
Telegram gateway |
prune.py |
Corpus cleanup |
pending.py |
Crash-safe unfinished work |
tooling.py |
Tool parsing and formatting |
skills.py |
Skill management |
Symbio supports legacy XML tags as well as the preferred Hermes-style tool format.
<tool_call>
{"name":"read_file","arguments":{"path":"config.json"}}
</tool_call><tool_call>
{"name":"terminal","arguments":{"cmd":"ls -la"}}
</tool_call><tool_call>
{"name":"note","arguments":{
"action":"add",
"target":"note",
"content":"The user likes coffee."
}}
</tool_call><note title="User Preference">
The user likes coffee.
</note><cmd>ls</cmd>Legacy formats remain supported for compatibility.
Symbio can learn both the user's name and its own name.
"My name is Alice."
"Call me Bob."
"You can call me Charlie."
"From now on call me Dana."
"Change my name to Eve."
"I go by Frank."
"Call yourself Jarvis."
"I will call you Friday."
"I'm going to call you HAL."
"Change your name to Jeeves."
"Set your name as Alfred."
The phrase:
"Your name is X"
is intentionally not treated as an assistant rename because smaller models can confuse it with a statement about the user's identity.
For an isolated install:
pipx install .Or:
pipx install /path/to/SymbioFor development:
pip install -e .The symbio and symb commands will then be available.
Note on the name: the distribution is
symbio-cli. Plainsymbioon PyPI is an unrelated multi-agent framework that had the name first, sopip install symbiowill fetch someone else's project — install from this repository as shown above. The commands you type are stillsymbioandsymb.
Legacy commands remain supported:
python main.py --telegram
python main.py --train- CUDA backend — Support NVIDIA/AMD hardware through PyTorch or Transformers.
- llama.cpp backend — Support GGUF models and broader hardware.
- LoRA optimization — Faster adapter swaps and more memory-efficient training.
- Better architecture separation — Further isolate inference, tools, training and storage.
- Sparse / quantized adapters — Explore QLoRA, 4-bit/8-bit models and sparse updates.
- MCP support — Model Context Protocol.
- Adapter marketplace — See
docs/adapter-marketplace.md. - Additional messaging platforms.
- Long-term weight pruning.
- More tools
- Live browser automation
- Permission-gated sandbox
- Automatic self-correction
- Learning new skills
- Web research memory
- Telegram bot
- Mixture of Agents
- Independently trainable worker adapters
- Golden-set regression protection
- Crash-safe training recovery
- Skill evaluation harness
- Automatic corpus cleanup
Contributions are welcome.
Some especially useful areas:
- Non-Apple hardware support
- Model/backend integrations
- Training performance
- Memory optimization
- Evaluation methodology
- Browser automation
- New worker types
- Testing and regression coverage
- Security hardening
- Documentation
See CONTRIBUTING.md for development setup, testing and pull-request guidelines.
If you find a bug, please include:
- Mac model
- Apple Silicon generation
- unified memory
- model preset
- relevant configuration
- error/log output
- steps to reproduce
Symbio is still experimental.
The biggest current limitations are:
- Apple Silicon only
- Local model size is constrained by unified memory
- Self-training can still overfit
- Skill evaluation primarily measures procedural recall
- Tool sandboxing is best-effort rather than a security boundary
- Multiple resident models can consume substantial memory
- Some features are experimental and may change
If you have a different Apple Silicon configuration, please report whether it works. Hardware reports are particularly useful for building a real compatibility matrix rather than guessing based on the chip name.
Apache 2.0
If Symbio is useful or interesting to you, a GitHub star helps other people discover it. :P
If you build something with Symbio, open an issue or discussion and show me what it learned.
