# Abstra Tasklets Guide

A **tasklet** is a plain Python stage that runs **once per task that arrives at it**. Another stage (form, hook, job, or tasklet) emits a task with `send_task`; the workflow routes it here by type; your file runs top-to-bottom with that task as its input.

Tasklets run **in the background** — there's no UI and no user waiting on them. They're triggered by tasks rather than by a person, and execute asynchronously whenever tasks are available.

## The task model (read this first)

A **task** has three parts:
- **payload** — a JSON-serializable `dict` of data.
- **type** — a string label (e.g. `"approved"`). Transitions route by type, so the same stage can branch to different next stages.
- **lifecycle** — `pending → locked → completed`. A run *locks* the task it's working on so no other run grabs it; on success it marks it *completed*; **if the run raises or never completes it, the task auto-reverts to `pending` and is retried later.** This is the built-in retry/idempotency mechanism — lean on it.

## API (`abstra.tasks`)

```python
from abstra.tasks import get_trigger_task, send_task, get_tasks, iter_tasks, get_sent_tasks
```

```python
get_trigger_task() -> Task                 # the task that triggered THIS run (tasklet-only; raises elsewhere)
send_task(type: str, payload: dict, show_warning: bool = True) -> None   # emit downstream; payload must be a dict
get_tasks(limit: int = 100, offset: int = 0, where: dict | None = None) -> list[Task]   # pending tasks at this stage (cap 10_000)
iter_tasks(where: dict | None = None) -> Iterator[Task]    # all pending tasks, auto-paginated
get_sent_tasks(limit=100, offset=0, where=None) -> list[Task]
```

### The `Task` object

```python
task["customer_id"]        # read a payload key (read-only — assignment raises)
task.get("note", default)  # safe read
task.payload               # the whole dict
task.id                    # uuid str
task.type                  # the type string
task.lock()                # mark locked (one run at a time)
task.complete()            # mark completed (never reprocessed)
```

**Prefer the context manager** — it locks on enter, **completes on clean exit, and reverts the task to `pending` on any exception** (so failures retry automatically):

```python
from abstra.tasks import get_trigger_task

with get_trigger_task() as task:      # lock → ... → complete (or revert on error)
    customer_id = task["customer_id"]
    # ... do the work; raising here means "retry later", finishing means "done"
```

If you don't use `with`, you must call `task.complete()` yourself on success — **a task you never complete stays `pending`, so it keeps being returned by `get_tasks`/`iter_tasks` and reprocessed in later runs even though you already handled it.**

## Workflow wiring

- **Routing by type:** `send_task("approved", {...})` only follows transitions configured for `approved`. Emit different types to branch.
- **Fan-out:** call `send_task` in a loop to spawn many downstream runs that process in parallel (one task → N child tasks).
- **Chaining:** tasklet A emits a task → tasklet B is triggered → … Build pipelines this way.
- **Fan-in / batch:** a stage can `get_tasks()` / `iter_tasks()` to drain and aggregate many pending tasks in one run (more common in jobs; see jobs_guide).

## Inside a tasklet you also have the full stage SDK

`os.environ.get(...)` · `from abstra.logging import logger` · `import abstra.tables as at` · `from abstra.connectors import run_connection_action` · `from abstra.messages import send_email` · `from abstra.common import get_persistent_dir, get_execution_id` · `from abstra.ai import run_agent` (tasklets are the most common host for agent loops).

## Footguns

- **Not completing the task.** No `complete()` (and no `with`) → it stays pending, so it keeps showing up in `get_tasks`/`iter_tasks` and gets reprocessed in later runs even after you handled it. Use `with get_trigger_task() as task:`.
- **`send_task` payload must be a `dict`.** Passing a list/str raises.
- **Side effects before the work can fail = double-charges on retry.** Since failures retry, make external effects idempotent (dedupe key, "create if not exists"), or do them last.
- **Calling `get_trigger_task()` in a non-tasklet.** It raises in forms/jobs/hooks — those aren't task-triggered.
- **Mutating the payload.** `task["k"] = v` raises; the payload is read-only. Build a new dict for `send_task`.

## Use-case map

| Use case | Shape |
|---|---|
| Queue worker (one task → one unit of work) | `with get_trigger_task() as task: ...` |
| Fan-out | `for row in rows: send_task("item", {...})` |
| Conditional routing | inspect payload → `send_task("approved" or "rejected", ...)` |
| Side effect on arrival (notify / write DB / call API) | read task → `run_connection_action`/`send_email`/`at.insert` |
| Document or AI processing | `run_agent`/`parse_*` on a file the payload points to → `send_task` result |
| Multi-step pipeline | chained tasklets, each emitting the next |

See `tasklets_examples.md` for complete, commented tasklet files.
