New to Kestra?
Use blueprints to kickstart your first workflows.
Label Postgres support tickets with Claude Message Batches at half price: claim rows, wait for the batch, write labels back, retry failures and notify Slack.
Label every pending support ticket in a PostgreSQL table with Claude through the Anthropic Message Batches API, which bills all usage at 50% of the standard API price. Each run claims a slice of tickets, submits them as one batch, waits for the batch to end, writes a category back to each ticket and posts a summary to Slack. Claims, retries, a size budget and an approval gate make it safe to run unattended on a large backlog.
size_check counts claimable tickets: pending, or submitted by a run that died more than 25 hours ago. With none, nothing_to_claim ends the run.if_large_batch pauses at approve_batch when more than approval_threshold tickets are claimable and no run was approved in the last 24 hours. After a resume, record_approval stores a 24 hour approval key in the KV store. A pause nobody resumes cancels the run after 24 hours.claim marks tickets submitted, oldest first, with FOR UPDATE SKIP LOCKED, so overlapping runs never take the same ticket. It stops at max_batch_size tickets or max_batch_bytes of request JSON and returns one request per ticket. If another run took them first, none_claimed ends the run.create_batch (io.kestra.plugin.anthropic.MessageBatch) submits the requests, and record_batch stores the batch id on each claimed ticket.wait_for_batch (io.kestra.plugin.core.flow.LoopUntil) runs retrieve_batch every minute until the batch ends, for at most 24 hours 30 minutes; the last check stores the results in an ION file.load_results (io.kestra.plugin.jdbc.postgresql.Batch) copies every result into ticket_enrichment_results. apply_results updates the tickets in one transaction: a label from labels marks the ticket done, any other answer needs_review, and an error or expiry sends it back to pending, or to failed once max_attempts is reached.summarize counts this run's tickets and notify posts the counts to Slack.errors branch cancels the batch, returns the run's claimed tickets to pending and posts a failure message.pending_rows trigger (io.kestra.plugin.jdbc.postgresql.Trigger, disabled by default) checks every 5 minutes and starts a run when at least 100 tickets are claimable or the oldest has waited more than an hour. It never starts a second run while one is going.category.A batch can take up to 24 hours to end. Kestra waits for it without holding a worker, keeps the claim, approval and retry state visible in each execution, cleans up through the errors branch when a step fails, and starts runs from the database with the JDBC trigger.
MessageBatch), plugin-jdbc-postgres and plugin-slack.CREATE TABLE support_tickets (
id bigserial PRIMARY KEY,
subject text NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
category text,
enrichment_status text NOT NULL DEFAULT 'pending'
CHECK (enrichment_status IN ('pending', 'submitted', 'done', 'needs_review', 'failed')),
enrichment_attempts integer NOT NULL DEFAULT 0,
claimed_at timestamptz,
enrichment_batch_id text,
enrichment_execution_id text,
enrichment_error text,
enriched_at timestamptz
);
CREATE TABLE ticket_enrichment_results (
batch_id text,
custom_id text,
result_type text NOT NULL,
output_text text,
error_message text,
loaded_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (batch_id, custom_id)
);
ANTHROPIC_API_KEY: creates, polls and cancels the batch.POSTGRES_URL: JDBC URL of the database, for example jdbc:postgresql://db:5432/app.POSTGRES_USER and POSTGRES_PASSWORD: a role that can read and update both tables.SLACK_WEBHOOK_URL: incoming webhook for the summary and failure messages.model (STRING, default claude-haiku-5-5): Claude model for every request.max_batch_size (INT, default 10000): most tickets one run claims.max_batch_bytes (INT, default 600000, minimum 50000): most bytes of request JSON one run builds.approval_threshold (INT, default 1000): claimable tickets above which a run pauses for approval.max_attempts (INT, default 3): failed results allowed per ticket before it is marked failed.category and enrichment_status in support_tickets and the Slack message.labels under variables, and the prompt parameter of claim, to fit your categories.pending_rows trigger to label new tickets automatically.support_tickets: category and enrichment_status per ticket; enrichment_error holds the answer or error behind needs_review, pending or failed.ticket_enrichment_results: one row per request, with result_type, output_text and error_message.{{ outputs.create_batch.batchId }}: the Anthropic batch id.{{ outputs.retrieve_batch.uri }}: ION file with one row per request (customId, type, outputText, stopReason, errorMessage, rawResponse).{{ outputs.summarize.row }}: this run's done, needs_review, pending and failed counts, as posted to Slack.kestra.queue.message-protection.limit), and claim returns every request in its result. Keep max_batch_bytes well below that limit; a larger backlog simply takes more runs.failed, for example after fixing the model name: UPDATE support_tickets SET enrichment_status = 'pending', enrichment_attempts = 0 WHERE enrichment_status = 'failed';On Kestra 2.0.5 OSS with Postgres 18 and claude-haiku-5-5.
| Run | Result |
|---|---|
| 20 hand-written tickets | 20 of 20 labeled correctly |
| 2,020 tickets | 4 runs, every ticket done, $0.035 in total |
| Over 1 MiB of requests in one run | Kestra rejects the task result and the tickets are released; max_batch_bytes prevents it |
Backlog above approval_threshold |
Pauses; after one approval, runs for 24 hours without pausing |
| Invalid model | Every request errors and nothing is billed; tickets end failed after max_attempts |
| Invalid API key, or wait limit reached | Batch canceled if one exists, tickets released, failure message |
| Claims 26 hours and 1 hour old | The old ones are taken over, the recent ones left alone |
| Two runs at once | Disjoint claims, no ticket labeled twice |
| Trigger | Fires on the age rule and on the count rule, never while its run is going |
labels and the prompt.needs_review tickets to a Slack channel or a review queue.max_batch_bytes together with kestra.queue.message-protection.limit to send bigger batches.