Skip to content

Temporal

Your activity gains one argument.

Temporal already knows when work must finish — that is most of what a workflow engine is for. offpeak needs the same fact. So the integration is not a package, an interceptor or a plugin: it is a deadline parameter threaded from the workflow into the activity, and a normal offpeak.run() call inside it.

There is no offpeak-temporal adapter, and there will not be one. run() holds no state between calls — it takes jobs and a deadline, returns one Result per job, and forgets you. Anything an adapter would own, Temporal already owns better.

Install

pip install "offpeak[all]" temporalio

The activity

import asyncio

import offpeak
from temporalio import activity


@activity.defn
async def summarize(docs: list[str], deadline: str) -> list[str]:
    """Summarize documents by `deadline`, on the cheapest venue that makes it."""
    jobs = [
        offpeak.job("claude-haiku-4-5", f"Summarize:\n\n{d}", max_tokens=512)
        for d in docs
    ]

    # run() blocks — it submits, then polls until the batch lands. Off the event
    # loop it goes, with a heartbeat so Temporal can tell "waiting on a batch
    # tier" apart from "the worker died".
    task = asyncio.create_task(asyncio.to_thread(offpeak.run, jobs, deadline))
    while True:
        done, _ = await asyncio.wait({task}, timeout=30)
        activity.heartbeat()
        if done:
            break

    results = task.result()
    activity.logger.info("offpeak settlement\n%s", offpeak.receipt(results))
    return [r.text or "" for r in results]

That is the whole integration. deadline is the one new argument.

The deadline is the consumer's need — summaries ready by 06:00 — not a request to run the work late. offpeak submits immediately and the venue is free to return any time before the window closes; observed batch completion on the queue board runs in minutes, not hours. The window buys the discount and the provider's freedom to choose when — never a delay you asked for.

The workflow

from datetime import timedelta

from temporalio import workflow

with workflow.unsafe.imports_passed_through():
    from .activities import summarize


@workflow.defn
class NightlyDigest:
    @workflow.run
    async def run(self, docs: list[str]) -> list[str]:
        # Resolve the deadline HERE, on the workflow's deterministic clock, and
        # pass it down as an absolute instant.
        deadline = (workflow.now() + timedelta(hours=8)).isoformat()

        return await workflow.execute_activity(
            summarize,
            args=[docs, deadline],
            # Must outlast the window itself — the activity is alive for as long
            # as the batch tier takes.
            start_to_close_timeout=timedelta(hours=9),
            heartbeat_timeout=timedelta(minutes=2),
        )

Three things that will bite you

Resolve the deadline in the workflow, not the activity

workflow.now() is Temporal's deterministic clock: it replays to the same instant every time. datetime.now() inside a workflow is not, and Temporal will tell you so. Computing the deadline once, in the workflow, and passing it down is both the deterministic move and the honest one — the deadline is a property of the business problem, not of whichever worker picked the task up.

Pass an absolute instant — never \"06:00\"

offpeak accepts "06:00" and resolves it to the next occurrence: today if that is still ahead, otherwise tomorrow. That is the right rule for a session and the wrong one under a retry policy. An activity that fails at 05:58 and retries at 06:01 would silently reprice against tomorrow morning — a 24-hour window where you meant three minutes, and a deadline nobody chose.

ISO 8601 from workflow.now() pins it. Every retry then targets the same instant, and once that instant is genuinely past, run() raises ValueError rather than inventing a new one.

start_to_close_timeout must exceed the window

The activity stays alive for the whole wait — that is the point. Set the timeout to the deadline window plus margin, or Temporal will kill the activity mid-batch and you will pay for work you cancel. Pair it with heartbeat_timeout and the loop above so a genuinely dead worker is still detected in minutes rather than hours.

Retries need no special handling

offpeak.run() does not raise on provider failure. A venue that dies at submit, poll or fallback comes back as a failed Result carrying the provider's message, and every other job in the batch settles normally. Exceptions are reserved for programming errors — a malformed deadline, or a model no configured venue supports.

So a Temporal RetryPolicy retries the things worth retrying (the worker died, the process was evicted) and does not retry a batch that already returned answers. If you want a partial failure to fail the activity, say so explicitly:

failed = [r for r in results if not r.ok]
if failed:
    raise RuntimeError(f"{len(failed)}/{len(results)} jobs failed: {failed[0].error}")

Quote before you commit the window

quote() makes no API calls and needs no key, so a pre-flight activity can price the wait before the workflow commits to it:

@activity.defn
async def price_the_wait(docs: list[str], deadline: str) -> float:
    jobs = [offpeak.job("claude-haiku-4-5", f"Summarize:\n\n{d}", max_tokens=512) for d in docs]
    return offpeak.quote(jobs, deadline=deadline).spread_usd

It belongs in an activity rather than in workflow code: it reads the clock to measure the window, which makes it non-deterministic in Temporal's sense even though it touches no network.

What you get back

offpeak.receipt(results) settles the run — what ran where, and what the hour was worth:

OFFPEAK SETTLEMENT ────────────────────────────
jobs      5000 (5000 ok, 120 sync fallback, 0 failed)
sla       5000/5000 met
venues    anthropic:batch 3000 · openai:batch 2000
tokens    41,000,000 in · 3,200,000 out
list      $2,469.00
paid      $1,234.50
captured  $1,234.50 (50.0%)
left      $29.63 on the table (120 job(s) missed the batch tier)
prices    snapshot 2026-08-28 — override via offpeak.prices
───────────────────────────────────────────────

Logging that line from the activity puts the spread in the same place you already look when a workflow misbehaves.

See the Quickstart for quote(), run() and receipts in isolation, Airflow for the same retelling under a scheduler, and the Spec for the full deadline semantics.