The orchestrator
BuildingEvery landscape needs a navigator. Yours is a small, contained service that does one job: take an intent — a wish — clean it, check it, and route it to the right agent, workflow, or expert on the right machine.
Most of Tixim is Rust, and we like it that way. But there are cases where low-volume traffic inside a contained domain is better served by a language built for reasoning pipelines than one built for throughput. The orchestrator is such a case: a few intents a minute, each deserving a careful multi-step decision, behind a narrow gRPC contract. It is written in Python, as a LangGraph pipeline, and it runs as an ordinary Tixim service — a rootless container with no capabilities, one declared token scope, and the same sandbox as everything else.
The Rust daemon is a dispatcher, not a decision-maker. It persists intents, queues them, checks that a runtime is healthy, and hands each one over. The orchestrator owns the intelligence: who is asking, what they want, whether policy allows it, and where it should go. If the orchestrator is down, intents wait in the queue instead of failing — that, too, is the daemon’s job.
An intent is a wish Shipping
The intent interface is how the idea you had while jogging reaches the
right instance on the right machine. tix intent submit, a message in a
chat room, a plan step proposed by an agent, or an expert calling the
intents_submit tool — all of them are the same SubmitIntent RPC. A
phone on the other side of the world speaks to your daemon over
iroh with a scoped token; from that point on,
a remote wish and a local one are indistinguishable.
Every intent then walks one graph:
The shape matters more than the names: policy sits on both sides of the model. A wish that is denied on source or threat grounds is never shown to a model at all. A wish that passes is classified by the model, and the classification is then checked again before anything executes.
Clean and check before anyone thinks Shipping
The first two nodes never call a model. classify_source reads the JWT
claims the daemon attached from the authenticated caller — a client
cannot declare itself trusted — and sorts the intent into one of five
source classes. normalize_intent canonicalizes the payload, hashes it,
pulls out every string a person or agent could have typed, and runs it
through a three-layer threat scan:
# ── Layer 1: Anticipator (deterministic, <5ms) ────────────────────
_run_anticipator(text, report)
# ── Layer 2: Pytector Sanitizer (deterministic) ───────────────────
_run_pytector_sanitizer(text, report)
# ── Layer 3: Pytector DeBERTa model (optional) ────────────────────
if enable_model_detection:
_run_pytector_model(text, report, threshold=injection_threshold)
Layer one is pattern matching: known injection phrasings, encoding tricks, homoglyph spoofing, path traversal, canary leaks. Layer two normalizes unicode and strips or scores suspicious spans. Layer three is a DeBERTa classifier for the injection attempts that don’t match a pattern. A critical finding from any layer marks the intent as threat-blocked, and the policy matrix turns that into a denial for every source class — there is no trust level that talks its way past an injection. The same unicode deny-list that guards Tixim’s own commits against Trojan Source runs in the content filters in front of the gateway, so an intent is scanned twice before a model reads it.
Policy itself is a matrix, not a prompt. The whole trust model fits in six lines:
_SOURCE_DEFAULTS = {
"system_internal": "auto_execute",
"trusted_manager_agent": "auto_execute",
"authenticated_operator": "approval_required",
"external_api_client": "suggest_only",
"unknown_or_unclassified": "deny",
}
After the model has classified the goal, a second pass may promote a directive — an operator asking to monitor, test or query a project runs without approval — but only for goals on an explicit safe list, and never in the direction of less scrutiny than the source class allows. Approval, when required, is a real LangGraph interrupt: the graph checkpoints, the operator approves or denies from the CLI, and the run resumes exactly where it stopped.
Analyze, then route Shipping
evaluate_intent is where a model finally reads the wish. It is a deep
agent with ten tools, all of them read-only: search the workflow
catalog, list skills, query the project’s knowledge base, resolve a
workflow reference. It cannot start, stop, or change anything. It must
answer with one small JSON object:
{
"goal_type": "<fix | build | deploy | query | …>",
"resolution_strategy": "<direct_response | existing_workflow | generate_workflow | adhoc_workflow>",
"resolution_payload": { },
"reasoning": "<why>"
}
Four strategies, each with a deterministic handler behind it. A direct response is the genie simply answering — no container, no workflow, no machinery spun up. An existing workflow is matched against the catalog and its parameters filled. A generated or ad-hoc workflow goes through composition. If the model fails, times out, or returns nonsense, the orchestrator does not stall: a scoring fallback ranks catalog candidates on tag overlap, capability match, relevance and historical success, auto-selects only when the winner is clear, and otherwise hands the decision back. Every branch is a record — the observations from each node are merged into an audit trail that stores hashes and metadata, never raw payloads.
Before composing anything, project_dispatch checks whether the project
already has work in flight for the same wish — by plan task, workflow,
catalog entry, intent hash, or plain goal-and-text overlap. If so, the
orchestrator asks the running workflow’s manager for status through its
chat room and answers you with that, instead of launching a duplicate. Two
people wishing the same thing get one run.
Experts, installed by the flake Building
Routing is only as good as the destinations it knows. A destination is an
expert: a flake can declare one or many under experts/<name>/expert.nix,
each carrying the routing information the decision needs —
what it dispatches on, what it is capable of, and how it describes itself:
{
entrypoint = "tix_expert_code_review.expert:CodeReviewExpert";
module = pkgs.python3Packages.buildPythonPackage { /* ... */ };
dispatch = [ "code.review" "code.analyze" ];
capabilities = [ "code" "review" ];
meta = { description = "Senior code review expert"; version = "0.1.0"; };
enabled = false; # nothing loads until `tix orchestrator plugins install`
}
Registering a flake does not load its code. The orchestrator never
scans the Nix store or the inventory for experts; it imports exactly the
modules named in the last reload payload the daemon pushed, and the daemon
only pushes what an operator explicitly installed. A bare tix flake add
cannot smuggle executable code into a running orchestrator.
Experts that are really services — a per-project
vx-maintainer instance, the wiki that is
your second brain, the agent on a business instance that manages your
calendar — declare a backing_service. When the daemon starts that
container it injects a freshly minted token; on every start the service
probes what it actually handles (for vx-maintainer, the projects mounted
into it), re-registers that metadata, and the router sees the refreshed
picture. Each expert’s token is scoped to its own dispatch patterns, with
two permissions — read and update intents — and the daemon re-checks the
pattern on every mutation, so an expert can never touch an intent that
wasn’t routed to it.
What is still being built is the last mile: today the model’s routing
decision chooses between answering, a catalog workflow, or a composed one,
and experts are reached through the runtime’s operation interface. Feeding
expert capabilities and per-instance metadata directly into that decision
— so the wish about your calendar lands on the calendar agent without a
workflow in between — is the work in progress, along with a per-project
policy matrix to replace the static one.
Many instances, one wish Building
Your orchestrator is personal: it runs on one of your instances, or several. The daemon’s control plane is split into four traits — queue, load, registry, catalog — each with a local implementation and a remote one that forwards to a peer daemon over the same authenticated gRPC. Which runtime handles an intent is an entry in a health-tracked implementation registry, so an orchestrator that is down marks intents as runtime unavailable and leaves them queued rather than failing them. Routing a wish from the instance that received it to the instance that owns the project is the remote half of that machinery; it compiles, and it is the next thing to ship.
Replace it if you like Shipping
The orchestrator is reachable only through thirteen gRPC methods on
OrchestratorRuntimeV2 — start, stop, resume, progress, events,
capabilities, checkpoints, threads, human messages, traces, and one generic
operation call — and it talks back to the daemon through exactly two write
paths, SyncIntentState and ProposeResolution. A replacement in Rust, or
anything else, implements those endpoints, advertises the same capability
set, honours the three scoped tokens the daemon sends with every run, and
flips one config key. The registry is already keyed for more than one
implementation. We think Python is the right choice for this subsystem; the
contract is there so that you don’t have to agree.