Skip to content

ADK on Tape — durable_app(...) in 15 lines

The wiring entrypoint. One call, four guarantees:

  • Every model call is journalled (so a re-driven run doesn't re-ask the model for choices it already made).
  • Every tool call is an effect with a declared semantics and status.
  • The Runner reconnects to the same session on re-drive — ADK's resumability is on.
  • The budget is enforced before each costed boundary and journalled after.
import tape
from tape.adk import durable_app

root_agent = ...   # your ADK LlmAgent

app, runner = durable_app(
    name="treasury",
    agent=root_agent,
    budget=tape.Budget(usd_cap=50, token_cap=2_000_000),
)

That's the whole wiring. → durable_app reference

The two tool patterns

@tape.effect — idempotent tools

For tools whose body performs IO and is idempotent on (run_id, idempotency_key) — typically because the upstream supports an idempotency key.

@tape.effect(compensate=reverse_wire, status_check=bank.wire_status)
def execute_sweep(account_id, amount_minor, target_mmf, tool_context):
    key = tape.idempotency_key(tool_context)
    return {"wire_id": bank.wire(account_id, amount_minor, target_mmf,
                                  idempotency_key=key)}

@tape.outbox_tool — non-idempotent upstreams

For tools whose upstream cannot be made naturally idempotent (a SWIFT wire, a one-shot side effect, anything where a double-fire is destructive). The tool body returns an intent payload only; the outbox reactor performs the dispatch.

@tape.outbox_tool(
    connector="bank.wire",
    business_key=lambda account, amount, date, **_: f"{account}:{amount}:{date}",
    status_check=find_wire,
    compensate=reverse_wire,
)
def wire_money(account: str, amount: int, beneficiary: str, date: str):
    return {"account": account, "amount": amount,
            "beneficiary": beneficiary, "date": date}

The decorator rejects at decoration time any non-idempotent tool that lacks business_key, status_check, or compensate (override with allow_unsafe=True after explicit review). The whole point is that an UNKNOWN outcome can be resolved — without one of those, there is no safe path forward. The server enforces the same contract at BeginEffect-time.

Capability connectors

The outbox reactor dispatches via a named connector. Register them at project import time — the scaffold's app/connectors.py shows the pattern:

from tape import connectors
from tape.connectors.http import HTTPConnector

connectors.register(HTTPConnector(
    name="bank.wire",
    endpoint="https://bank.example/wires",
    observe_endpoint="https://bank.example/wires/lookup",
    compensate_endpoint="https://bank.example/wires/reverse",
))

Built-ins: HTTPConnector (POST + X-Tape-* headers; urllib-based, no extra deps), PubSubConnector (publish to a topic + the matching subscriber helper). Implement your own by implementing the EffectConnector protocol — dispatch, observe, compensate.

Running the reactor

The reactor process needs to be able to rebuild your Runner. Provide a build_runner factory:

def build_runner():
    _app, runner = durable_app(
        name="treasury",
        agent=_build_root_agent(),  # fresh agent each call
        budget=tape.Budget(usd_cap=50),
    )
    return runner

Then:

tape-reactors --runner-from app.agent:build_runner --url tape://localhost:7878

or, in tape.yaml, set agent.runner_factory: app.agent:build_runner and tape dev / tape deploy gcp wire it through.

Java

Google's Java ADK ships at com.google.adk:google-adk on Maven Central. Tape's Java adapter mirrors the Python surface — TapePlugin + TapeSessionService, with a convenience wiring entrypoint.

The dep is provided-scope in tape-java, so non-ADK callers of TapeClient aren't forced to pull it in. Add google-adk to your agent's pom alongside tape:

<dependency>
  <groupId>dev.tape</groupId>
  <artifactId>tape</artifactId>
  <version>0.1.0</version>
</dependency>
<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk</artifactId>
  <version>1.2.0</version>
</dependency>

TapeAdkApp.wire(...) — the Java port of durable_app

import dev.tape.DurableApp;
import dev.tape.adk.TapeAdkApp;
import com.google.adk.agents.Runner;

try (TapeAdkApp app = TapeAdkApp.wire(
        new DurableApp.Config()
            .name("treasury")
            .budget(new DurableApp.Budget(50.0, 2_000_000)))) {

    Runner runner = Runner.builder(rootAgent)
        .plugins(List.of(app.plugin()))
        .sessionService(app.sessionService())
        .build();

    runner.runAsync(/* ... */).blockingSubscribe();
}

TapeAdkApp.wire(...) returns a bundle that shares one TapeClient between the plugin and the session service — closing the bundle closes the client.

What the plugin wires

ADK callback Tape RPC
beforeRunCallback BeginRun
afterModelCallback RecordDecision
beforeToolCallback BeginEffect (with CONFIRMED short-circuit)
afterToolCallback CompleteEffect(CONFIRMED)
onToolErrorCallback CompleteEffect(FAILED)
afterRunCallback EndRun(TERMINAL)

Position is by call order: the k-th model call is decision_index = k-1; each tool call is keyed to the most-recent decision plus a per-(decision, tool) call index — same alignment as the Python adapter. The plugin is safe to share across concurrent invocations (per-invocation bookkeeping is keyed by invocationId).

TapeSessionService — the persistence seam

Implements ADK Java's BaseSessionService over Tape's gRPC. The in-memory base contract runs first (state-delta application, temp: filter, last-update bookkeeping); committed events (partial != true) then persist to Tape in one round-trip. Partial streaming events stay in memory.

TapeClient client = new TapeClient("tape://localhost:7878");
BaseSessionService sessionService = new TapeSessionService(client);

Session created = sessionService.createSession("treasury", "cfo",
    new ConcurrentHashMap<>(), /* sessionId */ "")
    .blockingGet();

Session fetched = sessionService.getSession("treasury", "cfo",
    created.id(), Optional.empty()).blockingGet();

Status (G4 in the parity matrix)

Shipped: wiring contract — BeginRun, RecordDecision, BeginEffect / CompleteEffect, EndRun, session persistence.

Follow-ups tracked in SDK_PARITY.md: model replay (short-circuit a recorded LlmResponse on re-drive) and budget admit/charge. Both are additive and don't change the wiring contract above.

TypeScript & Go

Tape's TS and Go SDKs ship DurableApp / OutboxTool / connector registries that mirror the Python surface (see the wiring table in tape/README.md). There's no Google ADK port for TypeScript or Go at the time of writing — when there is, the adapter shape will be the same as the Java one: a plugin + a session service over the existing TapeClient.

For now, TS / Go agents talk to Tape directly via the TapeClient / tape.Client RPCs, or via durableApp(...) / tape.NewDurableApp(...) if they want the wiring helper without the framework adapter.

See also