Vihren is an ADK for durable agents built on Go and Temporal. Temporal provides the durability: workflows survive crashes and worker replacement, and they can run for days or months, because Temporal records and replays their execution.
This durability has a cost. If we structure our agents incorrectly, that cost can become prohibitive.
Temporal achieves durability through recording workflow events. Those events are sufficient to recover the full workflow state at any moment. In particular, the events for an activity contain its arguments and return value in serialized form. Event data can grow quickly, so Temporal Cloud limits each payload to 2 MB. Ideally our payloads should be much smaller than that.
If we write a durable agent where every LLM call and every tool call is an activity, a naive implementation generates large workflow events. For a long-running conversation we often need to send the whole history with each LLM call. As the conversation progresses, the space needed for workflow events grows not linearly, but quadratically. Tool calls and their results add more data that accumulates as the workflow progresses.
Vihren v0.3.0 offers a solution: typed claim-check values for Go workflows. Activities can offload large values and return a small handle. The workflow can route those handles to downstream activities without fetching and deserializing the original values when it does not need them.
The blob API reference documents the exact types and
operations. The conversation-storage example
runs the model described here, and the v0.3.0 release
records the shipped scope and limitations.
The O(n²) storage problem
The obvious failure mode is one huge value: a PDF, a browser screenshot, a bundle of retrieved documents or a tool log. Those can hit Temporal limits on their own.
But another problem is a value that grows over time and then gets copied again on every step. This is what happens with LLM sessions.
Consider a simple model/tool loop:
- Call the model.
- Append the model reply to the conversation.
- Call a tool.
- Append the tool result.
- Repeat.
If every turn passes “the full conversation so far” into the next activity, the bytes written to history grow like this:
- Turn 1 stores 1 unit of conversation.
- Turn 2 stores 2 units.
- Turn 3 stores 3 units.
- Turn
nstoresnunits.
The total is 1 + 2 + 3 + ... + n, or roughly n^2 / 2.
A 50-turn conversation does not cost 50 units of history. It costs roughly 1,250. Replay slows down, storage grows, and eventually the workflow hits a boundary it cannot cross.
The first fix is not to store the session like that. Instead we store each item in a content-addressable object store. The session then consists of a list of handles to the individual items.
This list is not strictly linear. If we pass the complete list on every turn, the handles themselves still accumulate quadratically. The constant is much smaller: with the default encoding a handle is roughly 100 bytes, rather than hundreds of kilobytes for a message in the example below. The handle overhead becomes material only after hundreds of turns. At around 500 turns, serializing the growing activity input and output adds roughly 25 MB of handle metadata.
Most agent conversations need summarization or compaction before they reach that length because the model context is already too large. If we need strict linear growth, we can represent the conversation as a linked list: the workflow carries one head reference and each stored node points to the previous node.
Two large-value problems
When a large value crosses a Temporal boundary we need to ask one question: does the workflow need to read the value?
There are two cases:
- The workflow needs the bytes. It may receive a large input from a client, a signal or an update. It may also receive an activity result and inspect it before deciding what to do next. Or it may generate a large value that it needs to send to an activity.
- The workflow only needs to pass the value from one activity to the next. It never needs to read the bytes itself.
Temporal already solves the first problem with its
ExternalStorage API. It transparently
moves large event payloads to an external backend and replaces them
with small claims in workflow history. When workflow or activity code
needs the value, Temporal fetches and decodes it first.
ExternalStorage is currently in Public Preview. Its APIs and
configuration may change before General Availability.
We can also use ExternalStorage for the second problem, but then
Temporal still fetches and decodes a value before giving it to workflow
code that does not use it. More importantly, this does not change the
shape of the data. If every turn returns the complete conversation, the
backend still stores a new and larger conversation on every turn. Our
workflow events become small, but our storage still grows
quadratically.
The second problem therefore needs a different value shape.
Passing values without reading them
Temporal does not have a direct activity-to-activity channel. An activity result first returns to the workflow and the workflow then schedules the next activity.
Vihren uses blob.Value[T] to let the workflow route that result
without reading it.
Stored values are identified by a content ID that is derived from
their serialized bytes. A blob.Ref[T] contains that ID and the size
of the stored value. Vihren wraps either a reference or an inline value
in blob.Value[T]:
type Ref[T any] struct {
ID ID `json:"id"`
Size int64 `json:"size"`
}
type Value[T any] struct {
Inline *T `json:"inline,omitempty"`
Ref *Ref[T] `json:"ref,omitempty"`
}
A value has two possible forms. A small value is stored inline. A large
value is stored separately and the Value[T] contains only its content
ID and size.
Activities use two operations to work with these values:
func ProcessDoc(
ctx context.Context,
in blob.Value[Doc],
) (blob.Value[Summary], error) {
doc, err := in.Resolve(ctx)
if err != nil {
return blob.Value[Summary]{}, err
}
return blob.Offload(ctx, summarize(doc))
}
Resolve gives the activity the input value. If the value is inline
there is no storage access. If it is a reference, Resolve fetches and
decodes the stored bytes.
Offload prepares the activity result. It serializes the result and
measures it. Small results stay inline. Large results are written to
storage and replaced with a reference.
The workflow can pass either form to the next activity without knowing which one it has. During replay a large value remains a small reference and its bytes are not fetched again.
Workflow code can also create an inline value with blob.Inline(value).
This is useful for the first input in a chain of activities.
We can now build the conversation from the previous section as a list
of blob.Value[Message]. Each large message is stored once. We replace
the quadratic repetition of message bodies with the much smaller
handle-list overhead described above.
Reading a value in the workflow
Passing a handle is the default, but it is not a restriction. After a value has entered this pass-through path, a workflow may later need to inspect it in order to decide which activity to schedule next.
A Temporal workflow must be deterministic, so it cannot read the
object store directly. Vihren provides blob.GetValue[T] for this
case. It schedules a blob.get activity, waits for the result and
decodes it for the workflow. In this example the workflow can now
branch on the document contents:
func ReviewWorkflow(ctx workflow.Context, ref blob.Ref[Document]) (Result, error) {
doc, err := blob.GetValue[Document](ctx, ref)
if err != nil {
return Result{}, err
}
needsReview := strings.Contains(doc.Text, "requires-human-review")
return Result{NeedsReview: needsReview}, nil
}
This operation materializes the value in the workflow, so we should only use it when the workflow really needs the bytes.
One backend under both paths
The two problems need different value flows, but they do not need
different storage backends. Temporal ExternalStorage,
blob.Value[T] and blob.GetValue[T] all use the same Vihren backend.
The backend is content-addressed. An ID is derived from the stored
bytes, so writing the same bytes produces the same ID. This makes
retries safe, allows identical values to be deduplicated and lets us
check the content when we read it back.
The backend also records which workflow owns each value. WriteMeta
carries that ownership information on a write and Owner identifies
the workflow. The complete storage interface is:
type Backend interface {
Put(ctx context.Context, data []byte, meta WriteMeta) (ID, error)
Get(ctx context.Context, id ID) ([]byte, error)
Exists(ctx context.Context, id ID) (bool, error)
AddOwner(ctx context.Context, id ID, owner Owner) error
RemoveOwner(ctx context.Context, id ID, owner Owner) error
}
The two paths use the interface in different ways:
- Temporal
ExternalStoragestores SDK payload bytes there. blob.Offloadstores typed values there and returnsblob.Value[T].blob.GetValue[T]reads those typed values through theblob.getactivity.
Both paths use the same Temporal data converter. blob.Offload uses it
when an activity offloads a typed value. Temporal uses it before
passing a normal payload to ExternalStorage. This keeps their encoding
and codec configuration consistent.
Because the backend is content-addressed, identical encoded payloads are stored only once. The same Go value can still produce different blobs if it is encoded in a different wrapper or serialization context.
What we share is the storage configuration, credentials and ownership metadata needed for future lifecycle management.
Preparing for ownership and garbage collection
A future collector cannot delete a blob just because one workflow has finished. Content addressing allows several workflows to refer to the same blob.
For this reason every write records an edge between the blob and the workflow that owns it:
blob -> workflow owner
If another workflow writes the same bytes, the backend records another
owner edge without storing another copy. The backend also exposes
AddOwner and RemoveOwner as primitives for future lifecycle work.
Vihren v0.3.0 does not yet implement ownership transfer or garbage collection. It records owner metadata and provides the operations on top of which we can build those protocols without changing the storage format.
Local storage
The first implementation of the backend is LocalBackend. It is meant
for local development and for agents that run on one machine.
The blob bytes are stored on disk using their content ID:
root/sha256/<digest>
The owner edges are stored in a SQLite database under the same root:
root/metadata.db
SQLite uses a write-ahead log (WAL) and retries busy operations. This allows multiple local agent processes to share one blob root.
Each write stores the bytes before it records the owner. If the process crashes between the two operations we may leave an unowned blob, which a future garbage collector can remove. We never leave an owner record that points to missing bytes.
Wiring
When we use Vihren’s embedded Temporal server,
WithLocalStorage configures
both storage paths:
server, err := embeddedtemporal.Start(
embeddedtemporal.WithLocalStorage("./data/blobs"),
)
if err != nil {
return err
}
defer server.Close()
_, err = server.StartWorker("tq", func(r worker.Registry) {
server.Storage().RegisterActivities(r)
r.RegisterActivityWithOptions(
ProcessDoc,
activity.RegisterOptions{Name: "process-doc"},
)
})
if err != nil {
return err
}
WithLocalStorage does three things:
- It configures Temporal
ExternalStoragefor workflow-readable large payloads. - It puts the blob store in each activity context, where
ResolveandOffloadcan use it. - It exposes
Storage().RegisterActivitiesso workers can registerblob.getfor workflow-sideblob.GetValue[T]reads.
This convenience option does not hide the lower-level API. When we
build a worker directly with worker.New, we can construct the same
storage runtime as a blob.Storage, add its interceptor to
worker.Options and register the blob activities ourselves.
The embedded Temporal setup guide shows both the
convenience and lower-level integration points.
Install
Add Vihren v0.3.0 to a Go module:
go get vihren.dev/vihren/[email protected]
The README storage quickstart provides the compact entry point; the release notes cover the module-path migration from v0.2 and the complete compatibility notes.
Try it
The repository includes a runnable example that measures the conversation problem from the start of this post:
go run vihren.dev/vihren/examples/conversationstorage/cmd/[email protected]
The example runs two workflows over embedded Temporal and the same local storage backend:
ConversationWorkflowcarries[]blob.Value[Message]. Each turn stores one new message and appends its handle. Earlier message bodies are not read or written again.NaiveConversationWorkflowcarries[]string. Each turn returns the whole transcript. Temporal external storage keeps that payload out of workflow history, but it still stores a new and larger copy.
Both workflows have external storage enabled. We are comparing the shape of their data, not external storage against no external storage.
Example output looks like this:
turns conversation bytes naive-conversation bytes
2 655606 950565
4 1261956 3031534
8 2474656 10732453
16 4900063 40290217
The exact byte counts depend on Temporal’s payload encoding. Over this range, storing each large message once produces near-linear backend growth. Storing the complete transcript on every turn produces the quadratic growth we want to avoid. The growing handle list still has quadratic metadata overhead, but with a much smaller constant.
What this does not solve yet
This release provides the storage foundation, but it does not complete the state lifecycle:
- Garbage collection: owner edges are recorded, but no reclaimer runs yet.
- Ownership transfer:
AddOwnerandRemoveOwnerexist, but the protocol for handing refs across workflow boundaries is not specified yet. - Cloud storage:
LocalBackendis the first backend. TheBackendinterface is the extension point for S3 or another cloud backend. - Provenance: content-addressed identities are the substrate for provenance and evaluation, but provenance APIs are separate future work.
Conclusion
Durable agents need to move large values across Temporal boundaries. If we pass those values directly, individual payloads can exceed Temporal’s limits. If we repeatedly pass a growing conversation or other aggregate state, the underlying storage grows quadratically.
Vihren v0.3.0 addresses both problems. Temporal ExternalStorage keeps
large workflow-readable payloads out of history. blob.Value[T] lets
workflows route large activity results as small typed handles without
fetching their bytes. Both paths use the same content-addressed
backend.
For conversations, this means that we can store each message once and pass a list of references instead of storing the complete conversation again on every turn. The reference list still grows quadratically, but its constant is small enough that normal conversation compaction should happen first. Applications that need strict linear growth can replace the list with a linked chain of stored nodes.
This release does not yet provide garbage collection, cloud storage or provenance. It provides the storage foundation on which those features can be added without changing how large values move through workflows.
Continue with the blob package reference for API details,
the WithLocalStorage reference for embedded
wiring, or the conversation-storage example to
run the comparison from this article.