Files
blog/flows-are-sessions.html
T

370 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flows Are Sessions, Not Pipelines: Why We Moved Our Agent Orchestrator from YAML to JavaScript — Tinqs Blog</title>
<meta name="description" content="We killed the static YAML DAG and rewrote our agent orchestration in 200 lines of JavaScript. Now a flow IS a session — you chat it, steer it, and it pauses for you at a human gate.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://www.tinqs.com/blog/flows-are-sessions">
<meta property="og:type" content="article">
<meta property="og:url" content="https://www.tinqs.com/blog/flows-are-sessions">
<meta property="og:title" content="Flows Are Sessions, Not Pipelines: Why We Moved Our Agent Orchestrator from YAML to JavaScript">
<meta property="og:description" content="YAML DAGs are dead. We rewrote our agent orchestration in JavaScript, made every flow a live session, and added a human-in-the-loop gate. The operator is the co-pilot, not the babysitter.">
<meta property="og:image" content="https://www.tinqs.com/img/og-cover.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Flows Are Sessions, Not Pipelines: Why We Moved Our Agent Orchestrator from YAML to JavaScript">
<meta name="twitter:description" content="YAML DAGs are dead. We rewrote our agent orchestration in JavaScript, made every flow a live session, and added a human-in-the-loop gate. The operator is the co-pilot, not the babysitter.">
<meta name="twitter:image" content="https://www.tinqs.com/img/og-cover.jpg">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Flows Are Sessions, Not Pipelines: Why We Moved Our Agent Orchestrator from YAML to JavaScript",
"datePublished": "2026-06-11",
"author": {
"@type": "Person",
"name": "Ozan Bozkurt"
},
"publisher": {
"@type": "Organization",
"name": "Tinqs Limited",
"url": "https://www.tinqs.com"
},
"description": "We killed the static YAML DAG and rewrote our agent orchestration in 200 lines of JavaScript. Now a flow IS a session — you chat it, steer it, and it pauses for you at a human gate."
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ── Tinqs Studio brand — post styles ── */
:root {
/* Studio near-black base */
--c-bg: #0B0C0E;
--c-bg-raised: #15171A;
/* Foreground */
--c-fg: #ECEEF1;
--c-muted: #8A95A3;
/* Family accents */
--c-lime: #B6FF3C;
--c-violet: #7C5CFF;
/* Borders */
--c-border: rgba(255,255,255,.07);
--c-border-strong: rgba(255,255,255,.12);
}
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--c-bg); }
body {
margin: 0;
padding: 0;
background: var(--c-bg);
color: var(--c-fg);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ── Post container ── */
.post {
background: var(--c-bg);
max-width: 720px;
margin: 0 auto;
padding: 48px 24px 60px;
}
/* ── Back link ── */
.post__back {
color: var(--c-muted);
text-decoration: none;
font-size: 0.875rem;
display: inline-block;
margin-bottom: 24px;
transition: color 0.15s;
}
.post__back:hover { color: var(--c-lime); }
/* ── Gradient title — lime → violet ── */
.post__title {
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
background: linear-gradient(90deg, var(--c-lime), var(--c-violet));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
font-size: 2.2rem;
line-height: 1.2;
margin: 0 0 16px;
}
/* ── Date pill ── */
.post__date {
display: inline-block;
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Consolas, monospace;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--c-muted);
border: 1px solid var(--c-border);
border-radius: 999px;
padding: 4px 14px;
margin-bottom: 16px;
}
/* ── Lead ── */
.post__lead {
color: var(--c-muted);
font-size: 1.08rem;
line-height: 1.7;
}
/* ── Body ── */
.post__body { font-size: 1rem; line-height: 1.7; }
.post__body p { margin: 14px 0; }
.post__body h2 {
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
font-weight: 600;
font-size: 1.6rem;
margin: 54px 0 6px;
padding-left: 16px;
border-left: 4px solid var(--c-lime);
line-height: 1.3;
}
.post__body h3 {
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
font-weight: 500;
color: var(--c-violet);
font-size: 1.15rem;
margin: 30px 0 4px;
}
.post__body h4, .post__body h5, .post__body h6 {
margin: 20px 0 4px;
}
/* ── Inline code ── */
.post__body code {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Consolas, monospace;
font-size: 0.84em;
background: var(--c-bg-raised);
color: var(--c-lime);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--c-border);
}
/* ── Code blocks ── */
.post__body pre {
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 16px 18px;
overflow-x: auto;
margin: 14px 0;
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Consolas, monospace;
font-size: 0.83rem;
line-height: 1.55;
color: var(--c-fg);
}
.post__body pre code {
background: transparent;
padding: 0;
border: none;
font-size: inherit;
color: inherit;
border-radius: 0;
}
/* ── Blockquote ── */
.post__body blockquote {
background: rgba(124, 92, 255, 0.06);
border: 1px solid rgba(124, 92, 255, 0.15);
border-left: 4px solid var(--c-violet);
border-radius: 0 8px 8px 0;
padding: 16px 18px;
margin: 18px 0;
color: var(--c-fg);
font-size: 0.94rem;
}
/* ── Links ── */
.post__body a { color: var(--c-lime); text-decoration: underline; text-underline-offset: 3px; }
.post__body a:hover { color: var(--c-violet); }
/* ── Strong ── */
.post__body strong { color: var(--c-lime); font-weight: 600; }
/* ── HR ── */
.post__body hr {
border: none;
border-top: 1px solid var(--c-border);
margin: 32px 0;
}
/* ── Figures ── */
.post__body figure { margin: 20px 0; }
.post__body figure img {
max-width: 100%;
border-radius: 12px;
border: 1px solid var(--c-border);
}
.post__body figcaption {
color: var(--c-muted);
font-size: 0.85rem;
margin-top: 6px;
}
/* ── Lists ── */
.post__body ul, .post__body ol { padding-left: 1.5em; margin: 10px 0; }
.post__body li { margin: 4px 0; }
/* ── Author ── */
.post__author {
display: flex;
align-items: center;
gap: 14px;
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid var(--c-border);
}
.post__author-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--c-violet);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.85rem;
flex-shrink: 0;
}
.post__author-info {
font-size: 0.85rem;
color: var(--c-muted);
line-height: 1.4;
}
.post__author-name {
color: var(--c-fg);
font-weight: 600;
}
</style>
</head>
<body>
<!-- POST -->
<article class="post">
<a href="/blog/" class="post__back">&larr; All Posts</a>
<span class="post__date">11 June 2026</span>
<h1 class="post__title">Flows Are Sessions, Not Pipelines: Why We Moved Our Agent Orchestrator from YAML to JavaScript</h1>
<p class="post__lead">Our YAML flow engine had seven bespoke node types just to fake a <code>while</code> loop. We threw it out and rewrote everything in 200 lines of JavaScript. The flow engine is gone. The flow IS the session. Here's what we learned.</p>
<div class="post__body">
<h2>The YAML Was a Compiler for a Language Nobody Wanted</h2>
<p>The old system was a static DAG. You defined nodes and edges in YAML, the engine walked them top-to-bottom, and when it finished it was done. No mid-run interaction. No branching. No retry. If you wanted a loop, you didn't use a <code>while</code> statement — you used an <code>agent-loop-decision</code> node type. In YAML.</p>
<pre><code class="language-yaml"># The old way: a "loop" was a bespoke node type
steps:
- agent-task: "generate document"
- agent-loop-decision:
condition: "check if quality &gt; 0.8"
if-true: "continue"
if-false: "repeat-step: agent-task"</code></pre>
<p>That's not configuration. That's a compiler for a language nobody wanted to write. Every orchestrator ends up here — GitHub Actions expressions, GitLab CI <code>rules</code>, Airflow's <code>BranchPythonOperator</code>, all of them start as "simple YAML config" and grow node types until they're Turing-complete nightmares held together by schema patches.</p>
<p>We had seven: <code>agent-task</code>, <code>agent-loop-decision</code>, <code>fork</code>, <code>conditional</code>, <code>agent-join</code>, <code>pipeline-stage</code>, <code>human-review</code>. Each one existed because YAML can't express control flow. You weren't writing a flow. You were filing paperwork to describe a flow.</p>
<p>The moment we knew it was wrong: someone asked "can I retry this step three times if it fails?" and the honest answer was "we'd need a new node type." When your config format needs an RFC to add a <code>for</code> loop, you've built a programming language by accident. Delete it.</p>
<h2>200 Lines of JavaScript Replaced Seven Node Types</h2>
<p>A flow is now an ES module. You export <code>default async (flow) => {}</code> and the runtime calls it. The API surface is five calls:</p>
<ul>
<li><code>agent(prompt, options)</code> — run one agent with a task</li>
<li><code>parallel(thunks)</code> — run many agents concurrently, await all</li>
<li><code>pipeline(items, ...stages)</code> — push items through stages</li>
<li><code>phase(name)</code> — label progress for the dashboard</li>
<li><code>human(config)</code> — pause and wait for a person</li>
</ul>
<p>Here's a real flow. It reviews a code route change with parallel researchers and a human gate. Ten lines.</p>
<pre><code class="language-js">// .pi/flows/flows/review-routes.flow.mjs
export const meta = { name: "review-routes", description: "audit routes for missing auth" };
export default async (flow) =&gt; {
const { agent, parallel, phase, human, task } = flow;
phase("find");
const findings = await parallel(["auth", "input"].map((d) =&gt; () =&gt;
agent(`Review ${d}: ${task}`, { agent: "researcher", model: "@planning" })
));
phase("review");
const gate = await human({ title: "Eyeball it", prompt: "anything to fix before I finish?" });
return { findings, gate };
};</code></pre>
<p>Why JavaScript wins is boring and fundamental: it has <code>while</code>, <code>if</code>, <code>try/catch</code>, and <code>parallel(thunks)</code> built in. The things our YAML needed custom schema types to fake are just language keywords. Bounded concurrency is a one-liner. Error recovery is a <code>try</code> block. A loop is <code>while (!approved)</code>. No plugin, no RFC, no new node type.</p>
<p>We migrated all flows YAML-to-JS on 2026-06-10. One-way conversion script, every flow reviewed and running in 24 hours. The YAML parser was deleted — not deprecated, not kept for backwards compatibility, deleted. There's no config path left to reach for.</p>
<p>The plan lives in code, not config. Config is for things that don't change. Agent orchestration changes every run.</p>
<h2>A Flow IS a Session</h2>
<p>The old model had a phantom problem. A flow was a card. A session was a separate card. The operator watched the flow run from a different window. There was a "New Flow" button that created a flow card, and a "Continue" button that attached a session to it, and the disconnect between "the thing running your work" and "the place you talk to it" was baked into the UI.</p>
<p>We killed that architecture. Every spawn is a session.</p>
<p>When you call <code>POST /api/flows/spawn {cwd, task, flowName}</code>, the session runs the flow inside itself with the <code>flow_run</code> tool. Steps stream inline into the chat — <code>flow:steps</code> injects progress into the session's own message stream. The session turns purple in the dashboard. It becomes the flow. One card. One identity. Persistent after the run finishes.</p>
<p>No "New Flow" button. No "Continue" button. You spawn a session; with a <code>flowName</code> it runs that flow, without one it opens an interactive operator session that designs a flow with you first. The dashboard branding is tinqs Studio. The control surface is the host card, live agent cards, run history, and a chat to steer the run.</p>
<p>There's no phantom card. No disconnect between "the thing running your work" and "the place you talk to it." It's one session. You're in the room.</p>
<h2>The Human Gate: Pause, Take Over, Approve, Continue</h2>
<p>Agents make mistakes. They guess when they shouldn't. They take irreversible actions because the prompt said "proceed." The model gets stuck and you sit there wondering — do I wait or abort?</p>
<p><code>flow.human()</code> is our answer.</p>
<p>When a flow hits a human gate, it stops. The dashboard shows the gate prompt: what to review, what to decide. The host session switches to takeover mode — coding tools are unblocked (normally the host is hands-off) and the system prompt becomes "flow is paused, help the operator finish this." You open files. You edit. You verify. You run commands.</p>
<p>To release the gate, reply <code>approve</code> or <code>done</code> or <code>lgtm</code>. The flow resumes. Any other message is a work instruction — the takeover session executes it but does not release the gate. The flow loops on <code>notes</code> until the human says go.</p>
<pre><code class="language-js">let approved = false;
while (!approved) {
const { notes } = await human({
title: "Review before push",
prompt: "Check the diff. Approve or tell me what to fix.",
});
if (notes.match(/^(approve|done|lgtm|looks good)/i)) approved = true;
else await agent(`Fix: ${notes}`, { agent: "implementer" });
}</code></pre>
<p>Two patterns this enables. First: review-approve-before-push gates — nobody ships untested code because nobody set the <code>auto-approve</code> flag to true. Second: the "agent is stuck" hand-off — the flow pauses, you take over the exact same workspace, fix the problem, type <code>continue</code>, and the flow keeps going.</p>
<p>The flow waits instead of guessing. This isn't a feature. It's an admission that some decisions shouldn't be automated.</p>
<h2>The Operator Is Your Co-Pilot</h2>
<p>The old way was a one-shot generator. Paste an objective, click Generate, get a YAML blob, pray it's right, run it, discover it's wrong 20 minutes in with no way to steer. We'd watch flows fail and think "I could have told it that before it started."</p>
<p>The new flow operator doesn't write the flow for you and walk away. It designs it with you.</p>
<p>Hit New Flow. A DeepSeek session opens — the same model that powers the dashboard, cheap at $0.28/MTok, steerable in natural language. It proposes a draft <code>.flow.mjs</code>, shows you the agents and phases and any human gate, and explains why. You tell it what to change. It does NOT launch until you say go. When you approve, it writes the flow file and spawns a separate host session, then attaches to monitor and report progress.</p>
<p>The operator is still in the chat when the human gate fires. It's still there when you want to change the plan mid-run. It doesn't go away after launch. Co-pilot, not autopilot.</p>
<p>There are three runner faces for the same engine: <strong>pi/dashboard</strong> (DeepSeek, cheap, steerable — the default), <strong>Claude Code</strong> (Workflow tool, one-shot fan-out for heavy research), and a <strong>cloud agent</strong> (remote deploy, clone, AWS). Pick by granularity and cost. The flow file is the same in all three modes.</p>
<h2>What We Learned</h2>
<p><strong>Numbers first.</strong> 43 out of 43 unit tests green. All flows migrated. The supervisor inbox — steering messages sent between steps — was silently dropping operator messages before 2026-06-10. You'd type "focus on the auth routes" and the flow never saw it. That's fixed now. Chat reaches the inbox, the inbox drains between <code>agent()</code> calls, and steering works.</p>
<p><strong>The inbox rule.</strong> The supervisor inbox is applied between <code>agent()</code> calls, never mid-step. Steering mid-step is undefined behaviour. We learned this the hard way — early versions tried to inject mid-agent and got corrupted state, partial outputs, agents that forgot what they were doing. Between steps is the right boundary. Respect it.</p>
<p><strong>Economics matter.</strong> DeepSeek V4 Pro at $0.28/MTok runs per-step. Per-step model override lets you swap in a premium reasoning model ($15/MTok) for the one critical call that needs it. $0.28 for routine, $15 for the hard parts. The three-tier strategy from our agent daemon applies at flow granularity too.</p>
<p><strong>What's next.</strong> Richer on-card flow display — a pinned step strip so you can see progress without opening the session. Attachable asset and agent-structure viewers in the flow card. Run replay for finished sessions after a page reload (the session persists, but you can't rewatch the stream yet).</p>
<p>But the principle is settled. A flow isn't a pipeline. A pipeline runs blind and reports back later. A flow is a pair-programming session where one of the pair happens to be code.</p>
<hr>
<p><em><a href="https://tinqs.com" style="color: var(--c-lime);">Tinqs Studio</a> is our agent-native development platform — git hosting, AI agents, and the flow engine described here. <a href="https://arikigame.com" style="color: var(--c-lime);">Ariki</a> is the survival colony sim we're building with it.</em></p>
</div>
<div class="post__author">
<div class="post__author-avatar">OB</div>
<div class="post__author-info">
<span class="post__author-name">Ozan Bozkurt</span><br>
CTO & Developer, Tinqs
</div>
</div>
</article>
</body>
</html>