agentleFS
kid-sidkid-sid/claude-spellbook/skills/temporal/skill.md

temporal

Skill190 starsChanged 6 months agoMITView on GitHub

Use when building or debugging the Agentex ADK temporal agent type — structuring workflows and activities, handling signal routing, managing state across replays, or diagnosing workflow failures and retry exhaustion. For standalone Temporal workers outside of Agentex, use general-temporal.

  • Reads credentials

We split this skill into the rules it gives an agent

3 rules. 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • 0

    Common

    Most of the 3 edited versions we read keep these.

  • 1

    Distinctive

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Read in this order to build a complete mental model of any agent:

    line 22
  2. Very specific:

    All Temporal agents extend BaseWorkflow.

    line 100
  3. Very specific:

    Never set manually for normal runs — agentex agents run --manifest manifest.yaml injects them from manifest.yaml:

    line 255

“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
skill.md

Temporal Workflows (Agentex)

Temporal is the durability layer for async Agentex agents. Every step is recorded as an immutable event; if the worker crashes, Temporal replays history to resume exactly where it left off.

When to Activate

  • Building or editing a Temporal-based agent (manifest.yaml has temporal.enabled: true)
  • Debugging workflow failures, retries, or signal handling
  • Adding activities or custom I/O to an existing workflow
  • Questions about failure recovery, event replay, or state persistence
  • Implementing the state machine pattern inside a workflow

Project File Reading Order

Read in this order to build a complete mental model of any agent:

  1. manifest.yaml — agent name, workflow name, queue name, env vars
  2. project/models.py — state shape stored in MongoDB between turns
  3. project/activities.py — real I/O (HTTP, DB, file); the only place non-deterministic work lives
  4. project/acp.py — 5-line config wiring ACP → Temporal (no handlers needed here)
  5. project/workflow.py — on_task_create (startup) + on_task_event_send (each user turn)
  6. project/run_worker.py — wires activities + workflow + starts the worker process

Core Concepts

Event Sourcing / Replay

Temporal records every decision as an immutable event before executing it:

Event 1: WorkflowStarted
Event 2: ActivityScheduled  (scrape_url, url1)
Event 3: ActivityCompleted  (scrape_url, url1) → "scraped text"
Event 4: SignalReceived      (RECEIVE_EVENT)
Event 5: ActivityScheduled  (scrape_url, url2)
          ← worker crashes here

On restart, Temporal replays events 1–4. For completed activities it returns the recorded result (no real I/O). Execution resumes at Event 5 for real.

Determinism rule: Workflow code must produce the same decisions on every replay.

  • ✅ Call activities for all I/O, random values, current time
  • ❌ Never use random, time.time(), httpx, file reads directly in workflow code
  • ❌ Never import I/O libraries at module level inside workflow files — use workflow.unsafe.imports_passed_through() if unavoidable

Workflow vs Activity

WorkflowActivity
PurposeOrchestration logic, state decisionsReal I/O (HTTP, DB, LLM calls)
I/O allowed❌ No — must be deterministic✅ Yes
Retried by TemporalWorkflow tasks retried on exceptionYes, via RetryPolicy
Runs inWorker process (sandboxed)Worker process (unrestricted)

ACP Entry Point (acp.py)

For Temporal agents, acp.py is just configuration. No handlers are registered manually — Temporal routes everything automatically.

import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")

from agentex.lib.sdk.fastacp.fastacp import FastACP
from agentex.lib.types.fastacp import TemporalACPConfig

acp = FastACP.create(
    acp_type="async",
    config=TemporalACPConfig(
        type="temporal",
        temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"),
    ),
)

ACP → Temporal mapping:

ACP RPC callTemporal action
task/createStarts a new workflow execution
event/sendSends RECEIVE_EVENT signal to the running workflow
task/cancelCancels the workflow execution directly

Workflow Structure (workflow.py)

All Temporal agents extend BaseWorkflow. Two methods to implement:

from typing import override
from temporalio import workflow
from agentex.lib.core.temporal.types.workflow import SignalName
from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow
from agentex.lib.environment_variables import EnvironmentVariables
from agentex.lib.types.acp import CreateTaskParams, SendEventParams

environment_variables = EnvironmentVariables.refresh()

@workflow.defn(name=environment_variables.WORKFLOW_NAME)
class MyWorkflow(BaseWorkflow):

    def __init__(self):
        super().__init__(display_name="My Agent")
        self._done = False  # set True to exit; usually stays False (cancelled externally)

    @workflow.run
    @override
    async def on_task_create(self, params: CreateTaskParams) -> None:
        # Called ONCE when the task is created.
        # Initialize state, send opening message, then block.
        await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=MyState.initial())
        await adk.messages.create(task_id=params.task.id, content=TextContent(author="agent", content="Ready!"))
        await workflow.wait_condition(lambda: self._done)  # keeps workflow alive

    @workflow.signal(name=SignalName.RECEIVE_EVENT)
    @override
    async def on_task_event_send(self, params: SendEventParams) -> None:
        # Called on EVERY user message. Runs as a Temporal signal handler.
        # All logic for responding to user input lives here.
        ...

wait_condition is mandatory in on_task_create. Without it the workflow exits immediately after startup and can no longer receive signals.


Activities (activities.py)

Activities are the only place with real I/O. Group them in a class, register the bound instance in run_worker.py.

import httpx
from pydantic import BaseModel
from temporalio import activity

SCRAPE_URL_ACTIVITY = "scrape_url"  # string name must match workflow.execute_activity() call

class ScrapeURLParams(BaseModel):
    url: str  # serialized to JSON by Temporal when dispatching to the worker

class ScraperActivities:
    @activity.defn(name=SCRAPE_URL_ACTIVITY)
    async def scrape_url(self, params: ScrapeURLParams) -> str:
        async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client:
            response = await client.get(params.url)
            response.raise_for_status()  # non-2xx → exception → Temporal retries
        return response.text[:8000]

Calling an activity from the workflow:

from datetime import timedelta
from temporalio.common import RetryPolicy

result: str = await workflow.execute_activity(
    SCRAPE_URL_ACTIVITY,
    ScrapeURLParams(url=url),
    start_to_close_timeout=timedelta(minutes=2),  # must finish within this window
    retry_policy=RetryPolicy(maximum_attempts=2),  # 2 total attempts before raising
)

State Management

State is a Pydantic model stored in MongoDB, keyed by (task_id, agent_id). Load → mutate in-memory → save.

# models.py
from agentex.lib.utils.model_utils import BaseModel

class MyState(BaseModel):
    turn: int = 0
    pending_urls: list[str] = []

    @classmethod
    def initial(cls) -> "MyState":
        return cls()
# Inside on_task_create
await adk.state.create(task_id=task_id, agent_id=agent_id, state=MyState.initial())

# Inside on_task_event_send
task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
state = MyState.model_validate(task_state.state)   # deserialize

state.turn += 1                                     # mutate in-memory

await adk.state.update(                             # persist
    state_id=task_state.id,
    task_id=task_id,
    agent_id=agent_id,
    state=state,
)

Important: adk.state.update inside a workflow executes as a Temporal activity. If the worker crashes before it runs, MongoDB retains the old state and the replay re-runs the handler from scratch using the old state — no corruption occurs.


Worker Entry Point (run_worker.py)

import asyncio
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).parent / ".env")

from agentex.lib.core.temporal.activities import get_all_activities
from agentex.lib.core.temporal.workers.worker import AgentexWorker
from agentex.lib.environment_variables import EnvironmentVariables

from project.activities import ScraperActivities
from project.workflow import MyWorkflow

env = EnvironmentVariables.refresh()

async def main():
    scraper = ScraperActivities()
    worker = AgentexWorker(task_queue=env.WORKFLOW_TASK_QUEUE, health_check_port=8084)
    await worker.run(
        activities=[*get_all_activities(), scraper.scrape_url],
        workflow=MyWorkflow,
    )

if __name__ == "__main__":
    asyncio.run(main())
  • get_all_activities() — built-in ADK activities (messages, state, tracing). Must always be included.
  • ScraperActivities() — instantiated here so scraper.scrape_url is a bound method.
  • WORKFLOW_TASK_QUEUE — injected by agentex agents run from manifest.yaml (agent.temporal.workflows[0].queue_name).

Environment Variables

Never set manually for normal runs — agentex agents run --manifest manifest.yaml injects them from manifest.yaml:

Env varSource in manifest
WORKFLOW_NAMEagent.temporal.workflows[0].name
WORKFLOW_TASK_QUEUEagent.temporal.workflows[0].queue_name
AGENT_NAMEagent.name
OPENAI_API_KEY etc.agent.env.*

A project/.env file is only needed when running acp.py or run_worker.py directly without the CLI.


Failure Handling

What Temporal handles automatically

FailureTemporal behaviour
Worker process crashReplays event history on next available worker; resumes from last checkpoint
Activity timeoutRetries per RetryPolicy; raises ActivityError into workflow after max attempts
Workflow task exceptionRetries the workflow task; workflow moves to FAILED after repeated failures

What the code must handle explicitly

Activity failure (after all retries): wrap workflow.execute_activity in try/except:

try:
    page_text = await workflow.execute_activity(
        SCRAPE_URL_ACTIVITY, ScrapeURLParams(url=u),
        start_to_close_timeout=timedelta(minutes=2),
        retry_policy=RetryPolicy(maximum_attempts=2),
    )
    scraped_pages.append((u, page_text))
except Exception as e:
    await adk.messages.create(task_id=task_id,
        content=TextContent(author="agent", content=f"Failed to scrape `{u}`: {e}"))
    # continue loop — one bad URL doesn't abort the batch

State load failure (MongoDB down): unhandled → workflow FAILED:

try:
    task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id)
    state = MyState.model_validate(task_state.state)
except Exception as e:
    await adk.messages.create(task_id=task_id,
        content=TextContent(author="agent", content=f"Failed to load state: {e}. Try again."))
    return

LLM call failure (OpenAI/litellm down): unhandled → workflow FAILED:

try:
    chat_completion = await adk.providers.litellm.chat_completion(llm_config=..., trace_id=task_id)
except Exception as e:
    await adk.messages.create(task_id=task_id,
        content=TextContent(author="agent", content=f"Summarization failed: {e}. Please resend URLs."))
    await adk.state.update(state_id=task_state.id, task_id=task_id, agent_id=agent_id, state=state)
    return

Failure handling pattern:

  • Wrap workflow.execute_activity in try/except — continue or message user on failure
  • Wrap adk.state.get_by_task_and_agent — return early and message user on failure
  • Wrap adk.providers.litellm.chat_completion — save state before returning on failure
  • Save state before every return so the next signal loads clean data

State Machine Pattern

For workflows with multiple distinct phases, use agentex.lib.sdk.state_machine:

from agentex.lib.sdk.state_machine.state import State

self.state_machine = MyStateMachine(
    initial_state=MyPhase.WAITING,
    states=[
        State(name=MyPhase.WAITING,    workflow=WaitingWorkflow()),
        State(name=MyPhase.PROCESSING, workflow=ProcessingWorkflow()),
        State(name=MyPhase.DONE,       workflow=DoneWorkflow()),
    ],
    state_machine_data=MyData(),
    trace_transitions=True,
)

# In on_task_create:
await self.state_machine.run()

# In on_task_event_send — trigger transitions:
await self.state_machine.transition(MyPhase.PROCESSING)

See state_machine/project/ in the repo for a full deep-research example.


ADK Providers

from agentex.lib import adk
from agentex.lib.types.llm_messages import LLMConfig, SystemMessage, UserMessage

# Non-streaming LLM call (litellm)
result = await adk.providers.litellm.chat_completion(
    llm_config=LLMConfig(
        model="gpt-4o-mini",
        messages=[SystemMessage(content="You are helpful."), UserMessage(content="Summarize this.")],
    ),
    trace_id=task_id,
)
summary = result.choices[0].message.content or ""

# Streaming LLM — auto-sends chunks to the UI
await adk.providers.litellm.chat_completion_stream_auto_send(
    task_id=task_id,
    llm_config=LLMConfig(model="gpt-4o-mini", messages=messages, stream=True),
    trace_id=task_id,
)

# OpenAI Agents SDK (with tools + MCP)
run_result = await adk.providers.openai.run_agent_streamed_auto_send(
    task_id=task_id,
    trace_id=task_id,
    input_list=conversation_history,
    tools=[MY_FUNCTION_TOOL],
    agent_name="Assistant",
    agent_instructions="You are helpful.",
    model="gpt-4o-mini",
)
final_history = run_result.final_input_list  # updated conversation for next turn

Tracing

# Span as context manager (auto-closes)
async with adk.tracing.span(trace_id=task_id, name="Turn 1", input=state) as span:
    await adk.messages.create(..., trace_id=task_id, parent_span_id=span.id)
    result = await adk.providers.litellm.chat_completion(..., trace_id=task_id)
    span.output = result

# Manual span (must call end() yourself)
span = await adk.tracing.start_span(trace_id=task_id, name="Turn 1", input={...})
# ... work ...
await adk.tracing.end_span(span_id=span.id, output={...})

Running Locally

# From the agent directory (e.g. url-summarizer-temporal/)
export ENVIRONMENT=development
agentex agents run --manifest manifest.yaml

# Debug mode — attach VS Code debugger on port 5679
agentex agents run --manifest manifest.yaml --debug-worker --debug-port 5679

Temporal UI (inspect workflow history, signals, failures): http://localhost:8080


Red Flags

  • I/O directly in workflow code — httpx, database queries, or LLM calls in a workflow function break determinism; on replay Temporal returns the recorded result instead of re-executing, so the actual network call never happens and the code path diverges; all I/O must be in activities
  • random, time.time(), or datetime.now() in a workflow — these return different values on every replay, causing divergence; use workflow.now() for timestamps and pass randomness through activity return values
  • on_task_create without await workflow.wait_condition(lambda: self._done) — without this the workflow function returns immediately after startup, the workflow execution completes, and all subsequent RECEIVE_EVENT signals are dropped because there is no running workflow to receive them
  • workflow.execute_activity without try/except — when an activity exhausts its retry policy Temporal raises ActivityError into the workflow; unhandled, this puts the workflow into FAILED state and the user never receives an error message; always catch and notify
  • Not saving state before return in a signal handler — returning from on_task_event_send without calling adk.state.update leaves MongoDB with the state from the previous turn; the next signal handler loads stale data and the agent loses its context
  • Not including get_all_activities() in run_worker.py — ADK built-in activities handle adk.messages, adk.state, and tracing; omitting them causes every adk.* call to fail at runtime with "activity not registered on this worker"
  • Importing I/O libraries at module level in workflow files — Temporal's sandbox isolates workflow execution; import httpx at the top of a workflow file either fails in sandboxed mode or subtly breaks determinism; use workflow.unsafe.imports_passed_through() if you must import, or move the import into the activity file

Checklist

  • Workflow code has zero I/O — all HTTP, DB, and LLM calls are in activities
  • No random, time.time(), or I/O imports at module level in workflow files
  • on_task_create ends with await workflow.wait_condition(lambda: self._done)
  • Activities use @activity.defn(name=CONSTANT) with string constant matching execute_activity() call
  • start_to_close_timeout and retry_policy set on every execute_activity call
  • workflow.execute_activity wrapped in try/except to handle exhausted retries
  • adk.state.get_by_task_and_agent wrapped — unhandled exception → workflow FAILED
  • adk.providers.litellm.chat_completion wrapped — state saved before returning on failure
  • State saved before every return inside signal handlers so next signal loads clean data
  • get_all_activities() included alongside custom activities in run_worker.py
  • WORKFLOW_NAME and WORKFLOW_TASK_QUEUE are injected by the CLI — not set manually

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.