Query icon
If icon
Exit icon
Pause icon
Set icon
MessageBatch icon
LoopUntil icon
Batch icon
Queries icon
SlackIncomingWebhook icon
Trigger icon

Classify Postgres Support Tickets with Claude Message Batches

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.

Categories
AIBusinessData

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.

How it works

  1. 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.
  2. 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.
  3. 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.
  4. create_batch (io.kestra.plugin.anthropic.MessageBatch) submits the requests, and record_batch stores the batch id on each claimed ticket.
  5. 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.
  6. 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.
  7. summarize counts this run's tickets and notify posts the counts to Slack.
  8. If a task fails, the errors branch cancels the batch, returns the run's claimed tickets to pending and posts a failure message.
  9. The 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.

What you get

  • Classification of a whole table at batch prices, without hand-written polling or retry code.
  • One claim per ticket, even when runs overlap, and recovery from runs that died.
  • A review state for answers outside your labels, instead of bad values in category.
  • A human approval before an unusually large backlog is sent.

Who it's for

  • Support and operations teams labeling tickets, emails or feedback stored in Postgres.
  • Data engineers who need LLM enrichment of database rows at batch prices.

Why orchestrate this with Kestra

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.

Prerequisites

  • An Anthropic API key with access to the Message Batches API.
  • A Slack incoming webhook.
  • plugin-anthropic 1.7.0 or later (MessageBatch), plugin-jdbc-postgres and plugin-slack.
  • PostgreSQL with these two tables:
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)
);

Secrets

  • 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.

Inputs

  • 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.

Quick start

  1. Create the two tables and insert a few tickets.
  2. Add the secrets to the namespace.
  3. Execute the flow, then check category and enrichment_status in support_tickets and the Slack message.
  4. Change labels under variables, and the prompt parameter of claim, to fit your categories.
  5. Enable the pending_rows trigger to label new tickets automatically.

Expected outputs

  • 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.

Things to know

  • Kestra limits each task result message to 1 MiB by default (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.
  • If a run fails after its batch was created, the batch is canceled and its tickets are released. Requests that had already finished are still billed, and a later run sends those tickets again.
  • To retry tickets marked failed, for example after fixing the model name: UPDATE support_tickets SET enrichment_status = 'pending', enrichment_attempts = 0 WHERE enrichment_status = 'failed';

Tested end to end

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

How to extend

  • Classify something else, such as priority, language or sentiment, by changing labels and the prompt.
  • Route needs_review tickets to a Slack channel or a review queue.
  • Raise max_batch_bytes together with kestra.queue.message-protection.limit to send bigger batches.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.