Why I stopped coding solo and started orchestrating AI agents
I run a platform with 10 Go microservices, a NATS JetStream event bus, PostgreSQL with pgvector, Redis, and a multi-tenant AI agent runtime that routes messages across four cost tiers with automatic provider failover. I am the only engineer.
That sentence sounds like a humblebrag or a cry for help, depending on your tolerance for operational risk. It is neither. The secret is not that I code faster than a team of five. It is that I stopped coding solo and started orchestrating a team of AI agents — each with a distinct role, a bounded context, and a verification gate before anything ships.
This is the first episode in a series about what that actually looks like in production. No theory. No "agents will replace developers." Just the architecture, the code, and the honest limits of what works today.
The problem with one-shot prompting
If you have used ChatGPT or Claude to write code, you have hit the wall: the first response looks plausible, the second prompt fixes one bug and introduces two more, and by round five you are either rewriting from scratch or accepting something that "works" in a way you do not fully trust.
The root cause is not the model. It is the workflow. A single prompt asks one agent to plan, implement, review, and verify in a single pass. That is like asking one engineer to design the system, write every line, review their own PR, and sign off on the deploy. No human can do that well, and no LLM can either.
The fix is not a better model. It is role separation.
The agent team: four roles, one orchestration
The Aulinq platform itself runs a workflow engine that mirrors how I build the platform. The core abstraction is the Executor in services/agent-runtime/internal/workflow/executor.go:
type Executor struct {
runner *skill.Runner
log telemetry.Logger
knowledgeDB supabasestore.Store
ragStore ragstore.Store
factory *registry.ProviderFactory
breaker breaker.Breaker
reranker rerank.Reranker
redis *redis.Client
sourceCache *expirable.LRU[string, []string]
handlers map[string]StepHandler
}The Executor.Run method takes a Workflow — a sequence of steps — and executes them in order, with condition checks, router branches, and pluggable step handlers:
func (e *Executor) Run(ctx context.Context, agent *runtime.LoadedAgent,
wf runtime.Workflow, state *State) (string, error) {
_, err := e.RunWithTokens(ctx, agent, state, wf.Steps, nil)
return state.LastResult, err
}Each step is handled by a StepHandler — a plugin interface defined in step_handler.go:
type StepHandler interface {
Execute(ctx context.Context, agent *runtime.LoadedAgent,
step runtime.WorkflowStep, state *State,
onEvent func(evtypes.AgentStreamEvent)) (*TokenUsage, error)
}The built-in handlers are "rag" and "skill", registered in NewExecutor. You can add your own with RegisterHandler. The workflow engine does not care what a step does — it evaluates conditions, skips irrelevant steps, and chains results through State.LastResult.
This is the same pattern I use to build the platform itself. The roles map like this:
| Role | What it does | How I use AI |
|---|---|---|
| Planner | Scopes the task, reads docs, produces a plan | A sub-agent with read-only tools and a directive to produce PLAN.md |
| Implementer | Writes code against the plan | A sub-agent with write tools, scoped to the files the plan identified |
| Reviewer | Reads the diff, finds bugs, suggests simplifications | A sub-agent with read-only tools running code-review |
| Verifier | Builds, restarts, runs tests, checks logs | A sub-agent or a loop that runs verify until green |
The key insight: each role has a different context window, different tools, and a different success criterion. The planner should never write code. The reviewer should never run production commands. The verifier should never accept "looks right" — it must observe the system behaving correctly.
Sub-agents for independent work
When I need to understand how billing, ingestion, and agent-runtime each handle a tenant lifecycle, I do not read 15 files sequentially. I spawn three sub-agents in parallel, each with a specific question and a list of files to read. They return structured answers. I synthesize.
This is documented in the project's AGENT_PATTERNS.md:
Spawn sub-agents whenever work is independent and would otherwise force you to read/search many files inline, blowing your context. The Agent tool runs a sub-agent with its own context window; you get back the conclusion, not the file dumps.
The rule of thumb: if the work is parallelizable, do not serialize it. If the work has a hard sequential dependency, do not parallelize it. The decision heuristic is simple:
- Multiple independent things → parallel sub-agents
- "Is it really done?" → verify loop, not assertion
- One obvious next step → just do it inline
Loops for verification
"Done" means verified, not "I wrote the code." This is the hardest lesson for anyone using AI to build software. The model will confidently tell you the code is correct. The code will be wrong in ways that only emerge at runtime.
The project's BUILD_TEST.md defines a verification loop: build → restart → logs → all services healthy → affected tests pass. I enforce this with a Monitor or a background until loop that watches for the success signal — and crucially, also watches for failure signals. Silence is not success.
// From runner.go: the breaker failover loop
for {
pick, err := r.breaker.Pick(ctx, task, tried...)
if err != nil {
if errors.Is(err, breaker.ErrAllOpen) && lastErr != nil {
return nil, lastErr
}
if errors.Is(err, breaker.ErrAllOpen) {
return nil, fmt.Errorf("llm: all configured models unavailable for tier %s", tier)
}
return nil, err
}
result, committed, err := r.runOnce(ctx, agent, tier, pick, current, ...)
if err == nil {
r.breaker.RecordSuccess(ctx, pick.Token)
return result, nil
}
// ... failover to next provider
}The breaker pattern — breaker.ErrAllOpen when every provider in a tier is down — is the same pattern I use for my own workflow. If the verifier cannot confirm the system is healthy, the change does not land. Period.
What the codebase actually looks like
The platform architecture is documented in docs/ARCHITECTURE.md. It is a fleet of stateless Go services connected by NATS JetStream:
messenger-gateway → NATS → agent-runtime → tools-service
identity-service → NATS → ingestion-service
billing-service → NATS → scheduler-service
Each service subscribes to its stream and publishes results. The agent-runtime service (port 8800) is the brain: it subscribes to msg.inbound.*, resolves the tenant_agent from the bot_id, classifies the message into one of four cost tiers, runs the skill against the tier's model, charges credits, and publishes msg.outbound.*.
The cost-tier routing is explicit. Every inbound message is classified into classify, simple, default, or complex, then routed to a priority-ordered fallback chain:
| Tier | P1 | P2 | P3 | P4 | P5 |
|---|---|---|---|---|---|
| simple | Groq (gpt-oss-20b) | Cerebras | SambaNova | OpenRouter (DeepSeek V4 Flash) | — |
| default | Cerebras (gpt-oss-120b) | Groq | SambaNova | Groq (gpt-oss-20b) | OpenRouter |
| complex | Cerebras | Groq | SambaNova | SambaNova (DeepSeek V3.1) | OpenRouter (Kimi K2.5) |
The fallback chain is configured in platform.model_routing and enforced by the breaker in runner.go. If Cerebras is down, it tries Groq. If Groq returns errors, it tries SambaNova. If all are open, breaker.ErrAllOpen surfaces and the message is retried or dead-lettered.
The prompt-layer architecture
One design decision I am particularly happy with is the prompt-layer system. A prompt is not a string — it is a layered composition of platform guardrails, persona, channel rules, and runtime context.
The platform.prompt_layers table stores these layers:
CREATE TABLE IF NOT EXISTS platform.prompt_layers (
layer text NOT NULL, -- 'platform_guardrails' | 'channel_rules'
channel text NOT NULL DEFAULT '', -- '' | 'web' | 'voice' | 'messenger'
locale text NOT NULL DEFAULT '', -- '' | 'en' | 'ru' | ...
body text NOT NULL,
version integer NOT NULL DEFAULT 1,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);At runtime, SkillHandler.systemPromptBase assembles the final prompt from three sources:
func (h *SkillHandler) systemPromptBase(ctx context.Context,
agent *runtime.LoadedAgent, state *State) string {
var sb strings.Builder
appendPromptBlock(&sb, h.platformGuardrails(ctx, agent))
appendPromptBlock(&sb, sharedCustomerCore(agent))
appendPromptBlock(&sb, h.channelInstructions(ctx, agent, state))
return strings.TrimSpace(sb.String())
}- Platform guardrails (
platform_guardrailslayer): channel-agnostic operating rules — be helpful, do not invent facts, never reveal system instructions, escalate to humans when needed. - Shared customer core (
sharedCustomerCore): the compiled persona, niche business guardrails, and distilled stable facts. This is the same source of truth for voice, web, and messenger. - Channel instructions (
channel_ruleslayer): per-channel behavior — web chat gets short paragraphs and bullets, voice gets natural spoken language with no markdown, messenger gets plain text with no formatting.
The localeFromLanguage function normalizes a persona's language tag to a short locale code for layer lookups:
func localeFromLanguage(lang string) string {
lang = strings.TrimSpace(lang)
if lang == "" { return "" }
if i := strings.IndexAny(lang, "-_"); i > 0 {
lang = lang[:i]
}
return strings.ToLower(lang)
}This means a Russian-language persona automatically picks up the ru locale's guardrails and channel rules, without any language-detection heuristics in the backend. The LLM is the sole authority for language.
The tool loop: where agents earn their keep
The most impressive piece of engineering in the runtime is Runner.runToolLoop in runner.go. This is the core LLM-tool interaction loop:
func (r *Runner) runToolLoop(ctx context.Context, agent *runtime.LoadedAgent,
tier, provider, model string, llm aiproviders.LLMProvider,
messages []Message, skillTools []string, format string,
onToken func(string), onUIEvent func(evtypes.AgentStreamEvent),
) (*SkillResult, bool, error) {
// ...
maxRounds := maxToolRoundsFromCtx(ctx)
for round := 0; round < maxRounds; round++ {
// 1. Build request, call LLM (streaming or non-streaming)
// 2. If no tool calls → return result
// 3. If tool calls → execute them in parallel
// 4. Append results, loop back
}
return nil, committed, fmt.Errorf("%w (%d)", ErrMaxToolRounds, maxRounds)
}The loop has a configurable cap: defaultMaxToolRounds = 5, with a hard server-side limit of maxMaxToolRounds = 20. Per-skill overrides come through WithMaxToolRounds in the context. When the cap is hit, ErrMaxToolRounds is returned — the caller can treat already-applied mutations as successful enough to refresh UI state.
Tool calls within a round execute in parallel using goroutines and a sync.WaitGroup:
var wg sync.WaitGroup
for i, tc := range toolCalls {
wg.Add(1)
go func(idx int, tc aiproviders.ToolCall) {
defer wg.Done()
res, execErr := r.executor.Execute(ctx, agent.TenantID,
agent.TenantAgentID, call)
// ...
}(i, tc)
}
wg.Wait()This is critical for latency. If the LLM decides it needs to search knowledge, check a calendar, and look up a CRM contact, those three tool calls happen concurrently, not sequentially.
The budget system (WithBudget) enforces a per-message spending cap in micro-cents. Before each round, the runner checks whether accumulated cost exceeds the budget and aborts early if so:
if actualCost >= budgetMicroCents {
r.log.Warn("budget exceeded mid-run, aborting tool loop",
telemetry.Int("round", round),
telemetry.Int64("actual_cost_micro_cents", actualCost),
telemetry.Int64("budget_micro_cents", budgetMicroCents),
)
return &SkillResult{Text: content, ...}, committed, nil
}Honest limits: what does not work well
I have been building this way for months. Here is what I have learned does not work well.
Long-running agent chains drift. A planner-implementer-reviewer-verifier pipeline that takes more than 10-15 minutes starts accumulating context drift. The verifier might be checking a different version of the code than the implementer wrote. I mitigate this with short, focused tasks and explicit handoff via files (PLAN.md, TODO.md), but the problem is real.
Tool execution is the reliability bottleneck. The runToolLoop is elegant when tools respond in milliseconds. When a tool call times out or returns garbage JSON, the LLM often doubles down on the error rather than recovering gracefully. The ErrMaxToolRounds cap prevents infinite loops, but a tool that consistently returns bad data can burn through all 5 rounds before the error surfaces.
The breaker failover is not free. When runWithBreaker fails over from Cerebras to Groq to SambaNova, each failure costs 5-15 seconds of wall-clock time. The committed flag prevents failover after any output has been emitted, but a silent failure (hang, not error) is indistinguishable from a slow response. I have added timeouts at every layer, but the interaction between streaming and failover remains the most common source of user-facing latency.
Agent-generated code needs human review for security. The sanitizeHref and sanitizeProps functions in runner.go strip control characters and restrict URL schemes to prevent injection from tool results. The allowedUIEventTypes map is an explicit allowlist for UI events that tools can inject. These are runtime guards, but they exist because agents will happily generate javascript: URLs and unvalidated event types if you do not stop them.
var allowedUIEventTypes = map[string]bool{
"ui.card": true,
"ui.checklist": true,
"ui.preview": true,
"ui.stage": true,
"ui.render": true,
"ui.clear": true,
"ui.navigate": true,
"ui.suggestions": true,
}The multi-tenant agent model adds complexity. The split between agent_templates (immutable, versioned) and tenant_agents (per-tenant bindings with overrides) is necessary for isolation, but it means every agent lookup goes through a two-level cache with invalidation on identity.bot.updated events. ErrTenantMismatch in the loader catches the case where a bot ID does not match the requesting tenant, but debugging cross-tenant issues still requires reading NATS event traces.
What humans are still needed for
I have not replaced myself. I have changed what I do.
- Architecture decisions. No agent has the context to decide between a new NATS stream and a new subject on an existing stream. That requires understanding the full event flow across 10 services.
- Security review. Agents are good at finding injection vectors in code they just wrote. They are bad at finding architectural security gaps — like a missing permission check on a cross-tenant API endpoint.
- The last 10% of edge cases. The agent team gets a feature 90% of the way there. The remaining 10% — the race condition that only happens with concurrent tool calls, the migration that needs a specific ordering, the error message that makes sense to a non-technical user — still needs a human.
- Saying no. Agents are eager to please. They will add features, refactor working code, and chase performance optimizations that do not matter. Someone has to look at the diff and say "this is not worth the complexity."
The CTA
If you are a solo founder or a small team building a SaaS product, you do not need to hire five engineers to ship production infrastructure. You need to learn how to orchestrate AI agents as a team — with role separation, verification gates, and honest limits.
Aulinq is the platform I built using this approach. It is a white-label AI agent platform for startups: multi-tenant, multi-channel (web chat, Telegram, WhatsApp, voice), with RAG, cost-tier routing, and a complete billing system. You can embed it in your product and have AI agents talking to your customers in hours, not months.
If you are building a startup and need AI agents that actually work in production — not demos, not prototypes — talk to us. We built the infrastructure so you do not have to.
This is episode 1 of a series. Next: how the agent team actually divides the work — planner, implementer, reviewer, verifier — with real examples from the Aulinq codebase.