# A New Primitive for Agentic Workflows

Jiho Park, Suyoung Hwang · 2026-05-26

By now the agentic LLM is a familiar idea: a model-driven system that reasons through a task, chooses actions, calls tools, observes the results, and iterates toward a goal on its own.

Strip away the autonomy and what remains is a state machine. An agent carries a state and transitions to new states by taking actions and folding in what they return. It runs until it reaches a terminal state, which may be a final answer to the user, a completed workflow, or a failure. The model has always been there; where we keep the machine is what has evolved.

The older abstraction is the open-ended agent loop. At each step, the model decides whether to call a tool or produce a final answer. Tool results are appended to the context, and the process repeats until the task is done. This is already a state machine; it is just implicit. The state, the transitions, and the current phase of execution are hidden inside the transcript and the model’s own reasoning.

More recent frameworks, such as LangGraph, make the machine explicit. Instead of relying on the model to remember what phase it is in, the application defines a graph of states, transitions, and allowed actions. The model still participates, but it no longer owns the whole control flow.

We implemented this in [Rust](https://blog.asteromorph.com/why-rust/). In this post we discuss the design problems we ran into while doing so.

## Where the machine lives

The design starts with a question: where do we put the state machine?

A graph framework is one good answer. In frameworks like LangGraph, the state is a shared blackboard. Each node is a function from state to state, reading from it and possibly writing back. Edges describe how execution moves from one node to the next. The result is a topology you can inspect, a central place where transitions are named, and runtime tooling that understands the shape of the program.

However, the shared blackboard does not scale gracefully with the pipeline. Every node can read and write all of it, so as the pipeline grows the blackboard grows with it, accumulating fields that matter to only a handful of nodes. A node that needs only two values still faces the entire blackboard, and nothing in its signature says which parts it actually depends on. Most of the pipeline ends up carrying state it never uses, and the wider the blackboard gets the harder it is to tell what any one node truly requires or what is safe to change.

Some frameworks let a node declare a narrower schema to specify the slice of the blackboard it wishes to use, so its dependencies are at least written down. However, the declaration is optimistic. A node that reads a field trusts that some upstream path has written it before execution reaches that point, but there is no way to guarantee this statically.

The blackboard also makes no distinction between kinds of state. Routing decisions often depend on the same state object that also carries prompt and task data, which can blur workflow control data and LLM-facing content. The wiring of the pipeline and the content of its prompts are stirred together, though they have little to do with each other.

We kept the composition as ordinary code instead, and let the library stop at the node. In plain code a node is just a function, and its signature already says what it consumes and what it produces. State lives where it is used and travels only as far as it is passed.

## What the library provides

With composition left to ordinary code, our library provides one primitive: the node.

```mermaid
flowchart LR
    T[Transcript_n] -->|LLM call| L[LLM step]
    L -->|tool call?| C[Tool execution]
    L -->|final answer?| F[Terminal]
    C --> T2[Transcript_n+1]
    T2 --> T
```

A node takes a transcript, makes one LLM call, and either executes the tool calls the model asked for, producing the next transcript and looping, or it yields a final answer and terminates. That is the irreducible unit, and it is the boilerplate worth absorbing.

You may notice that this is identical to the implicit state machine of the open-ended agent loop. The difference is that the node is a single step in that machine, not the whole thing. In a standalone agent, the transcript is the agent's own private state; here it is provided from outside and handed back out. The host code owns the transcript, feeds it to nodes, and may transform it in between.

Building several of these and composing them into something larger stays with the user. Composition is programming, and the user already has a language for it; the library does not need to add one. Its job ends at the node.

## Defining a node

We build the node from a config file. The config names the model, the system prompt, the tools the model may call, and the shape of the answer it must return. Everything that decides how the node behaves lives in one static artifact. This design gives us two useful properties almost for free.

**Resume.** We wanted a node's result to carry across runs instead of being recomputed each time. Without that, every rerun repeats work that has already been finished. A pipeline interrupted halfway re-runs the nodes it had already completed, and iterating on a pipeline's last stage re-pays for every stage before it. What this calls for is a content-addressed cache.

Content-addressed building is a well-worn pattern, the one that sits underneath Nix, Bazel, container image digests, and Git. An output is named by a hash of the inputs that produced it. The first run computes the output and stores it under that name. Every later run with the same inputs finds it already there and returns it for free. Only a change to the inputs forces a recompute.

To apply this to a node, we needed its identity expressed as a single computable value. That is straightforward here, because the node is built entirely from a config file. The prompts are text. The model name, sampling parameters, tool list, and output schema are all plain values. Since everything can be hashed, we can hash them together into a single fingerprint that moves whenever any input affecting the node's behavior changes. That fingerprint is the name the node needed, and pairing it with the node's input gives the cache its key.

None of this makes a node a pure function. It wraps a language model, and the same prompt can return different text on two calls, so a hash of the inputs cannot name one fixed output the way it does for a build system. But the node's contract need not match the model's. The model stays non-deterministic, and yet the node, seen from outside, returns the same output for the same input. The node presents a pure-shaped interface over a substrate that is not pure.

```mermaid
flowchart LR
    Cfg[Config] -->|hash at build time| FP[Fingerprint]
    In[Input] --> K
    FP --> K[Key]
    K --> Q{In cache?}
    Q -->|hit| O[Cached output, free]
    Q -->|miss| L[LLM call]
    L --> S[Store]
    S --> O
```

This is a weaker promise than a build system makes: the first call still runs a non-deterministic model, so what gets reproduced is the cached result, not a re-derivation. In practice that is enough. Reruns cost nothing and never repeat work, and resuming an interrupted pipeline becomes an ordinary run whose earlier nodes happen to hit the cache. If you change one node deep in a pipeline, only that node and the ones downstream of it lose their fingerprint match; everything upstream loads from cache. Iterating on the last stage of a long pipeline no longer means repaying for the first.

**Type safety.** We wanted a node to return a typed value. A node whose output carries only implicit guarantees forces downstream code to trust, blindly, that the fields it expects are present and have the types it expects. When that trust is misplaced, the failure does not surface at the node. It surfaces wherever the missing or mistyped field is first read, far from the node that produced it. And because a model returns different text on different inputs, the failure may show up only on some inputs, or only after some iterations. This is a kind of bug that is hard to reproduce and harder to locate.

Rust's type system exists to catch exactly this, turning a mismatched field into a compile error instead of a runtime surprise. But the compiler can only check a value against a known type, and a node's output is whatever shape we asked the model for. To bring the node inside the type system, we need a host-language type that describes that shape. Where does it come from?

It is already in the config. The config carries a `response_format`, the shape of the answer the model must return, and we treat that schema as the single source of truth. It is used twice: as a request parameter for the LLM APIs that support structured output, and as the source for a generated host-language type. One definition feeds both, so there is nothing to keep in sync by hand.

The generated type is what fits the node into the type system. The node's signature now states what it produces, and the compiler holds it to that. A response that does not match is caught at the node's boundary, where the problem actually is, instead of propagating downstream as a plausible-looking but wrong value. Past that boundary, code can read the fields without rechecking them, and if expectations later drift, the mismatch surfaces as a compile error rather than a runtime one.

The output schema being part of the config ties the two properties together. The fingerprint hashes the whole config, schema included. Edit the schema and the fingerprint moves, the cache invalidates, the node re-runs; edit the prompt and the same thing happens. The node's identity tracks every input that could change what it produces, so the cache stays correct by construction rather than by anyone remembering to bump a version.

## Interception layer

Collapsing the loop into a single primitive has a cost of its own. The steps inside a node, the LLM call and the tool executions, are no longer visible to the caller. A pipeline that only sees a transcript go in and a transcript come out cannot hold a risky tool call for approval before it runs, redact a secret before it reaches the model, or record what a tool returned.

So we open the loop back up, but only at named points. The node exposes callbacks for the useful moments in its cycle: before an LLM call, after an LLM step, and around each tool execution. A callback receives the relevant state, may inspect it, and may hand back a modified version.

This keeps the node as the unit of composition while giving the caller a precise handle on what happens inside it. The topology stays in ordinary code, and the interception layer covers the one thing ordinary code around the node could not otherwise reach.

## Orchestration and the agent

It is worth being precise about what a graph framework is for: sequencing work, branching on results, fanning out, and retrying. That is orchestration, and orchestration is a general problem. It has little to do with language models. A graph package solves it well, and solves it for any kind of work.

However, we are wary of designs that express both orchestration and the LLM loop inside the same graph abstraction. If control state and prompt content land in one structure, orchestration and the model stop being separable. Two concerns that could each stand on their own become one thing that is harder than either.

So we kept them apart. Orchestration stays in ordinary code, or in a general workflow package we may implement later. The node is the atomic unit for a single, well-formed interaction with a language model, with a typed result, a cache, and an interception layer. Because the node is just a function, it composes into whatever workflow you already have, a graph included. The state machine is still there. We just stopped asking one library to own all of it.