How to annotate tools with @undolog_tool

Prerequisites

  • Install the Python SDK
  • A running UndoLog proxy on http://localhost:8080
  • A PostgreSQL instance with the UndoLog migrations applied

What you'll build

You will annotate three async Python functions as UndoLog tools: a SAFE search, a COMPENSABLE email send with an undo handler, and an IRREVERSIBLE database deletion that requires human approval. You will then write an agent loop that handles the approval gate.

Steps

1. Import the decorator and tier types

from undolog_sdk import undolog_tool, ToolTier
from undolog_sdk.tier import CompensationDescriptor
from undolog_sdk.decorators import AwaitingApprovalError
from undolog_sdk.session import UndoLogSession

2. Annotate a SAFE tool

@undolog_tool(tier=ToolTier.SAFE)
async def search_web(query: str) -> str:
    return f"simulated results for: {query}"

SAFE tools bypass the effect log entirely. The function executes immediately and no record is written.

3. Annotate a COMPENSABLE tool with a compensation descriptor

Capture the arguments before execution so the compensation function knows what to undo.

async def undo_send_email(to: str, subject: str, original_message_id: str) -> dict:
    return {"status": "recalled", "message_id": original_message_id}
 
@undolog_tool(
    tier=ToolTier.COMPENSABLE,
    compensation=CompensationDescriptor.new(
        "undo_send_email",
        args={"to": "{to}", "subject": "{subject}", "original_message_id": "{message_id}"},
    ),
)
async def send_email(to: str, subject: str, body: str, message_id: str) -> dict:
    return {"status": "sent", "message_id": message_id}

The CompensationDescriptor.new() shorthand uses default version (1.0.0), max_retries=3, and retry_backoff_ms=1000. Pass a full CompensationDescriptor to override any of these:

CompensationDescriptor(
    fn_name="undo_send_email",
    fn_version="2.0.0",
    args={"to": "{to}"},
    max_retries=5,
    retry_backoff_ms=2000,
)

4. Annotate an IRREVERSIBLE tool

@undolog_tool(tier=ToolTier.IRREVERSIBLE)
async def delete_database(db_name: str) -> dict:
    return {"status": "deleted", "db_name": db_name}

When the proxy intercepts this call, it creates a pending approval record and returns HTTP 202. The decorator raises AwaitingApprovalError instead of executing the function body.

5. Handle approval in an agent loop

async with UndoLogSession(org_id="org-demo") as session:
    # SAFE: executes immediately
    results = await search_web(query="latest news", _session=session)
 
    # COMPENSABLE: logs effect, executes, commits
    await send_email(
        to="user@example.com",
        subject="Hello",
        body="World",
        message_id="msg-001",
        _session=session,
    )
 
    # IRREVERSIBLE: raises AwaitingApprovalError
    try:
        await delete_database(db_name="prod-db", _session=session)
    except AwaitingApprovalError as err:
        print(f"Approval required: {err.approval_id}")
        print(f"Tool: {err.tool_name}, Step: {err.step_index}")

The pending approval appears at GET /approvals?state=pending. Resolve it with:

curl -X POST "http://localhost:8080/approvals/${APPROVAL_ID}/approve" \
  -H "Content-Type: application/json" \
  -H "X-Org-Id: org-demo" \
  -d '{"actor": "admin@example.com"}'

Approval triggers auto-execution: the proxy runs the tool and commits the result inline. The agent then retries the same step and receives the cached result instead of executing the function again.

Verify it works

Save the complete example to test_annotations.py:

import asyncio
from undolog_sdk import undolog_tool, ToolTier
from undolog_sdk.tier import CompensationDescriptor
from undolog_sdk.decorators import AwaitingApprovalError
from undolog_sdk.session import UndoLogSession
 
@undolog_tool(tier=ToolTier.SAFE)
async def search_web(query: str) -> str:
    return f"results for: {query}"
 
@undolog_tool(
    tier=ToolTier.COMPENSABLE,
    compensation=CompensationDescriptor.new("undo_send_email"),
)
async def send_email(to: str) -> dict:
    return {"status": "sent", "to": to}
 
@undolog_tool(tier=ToolTier.IRREVERSIBLE)
async def delete_database(db_name: str) -> dict:
    return {"status": "deleted"}
 
async def main():
    async with UndoLogSession(org_id="org-test") as session:
        r1 = await search_web(query="hello", _session=session)
        print("SAFE:", r1)
        r2 = await send_email(to="a@b.com", _session=session)
        print("COMPENSABLE:", r2)
        try:
            await delete_database(db_name="test", _session=session)
        except AwaitingApprovalError as e:
            print(f"IRREVERSIBLE blocked: approval_id={e.approval_id}")
 
asyncio.run(main())

Run it:

python test_annotations.py

Expected output (with a running proxy):

SAFE: results for: hello
COMPENSABLE: {'status': 'sent', 'to': 'a@b.com'}
IRREVERSIBLE blocked: approval_id=<uuid>

Troubleshooting

Symptom Cause Fix
RuntimeError: Tool 'send_email' requires a session Missing _session kwarg in call Pass _session=session to every decorated tool
ValueError: Compensable tools require a compensation descriptor COMPENSABLE tier with no compensation= arg Add compensation=CompensationDescriptor.new(...)
UndoLogClient raises ConnectError Proxy not running Start proxy on localhost:8080 or set UNDOLOG_PROXY_URL
Tool call hangs forever No response from proxy Check proxy logs with docker compose logs -f proxy
Approval not found on retry Session or org ID mismatch Verify X-Org-Id header matches session org_id

Next steps