powered by

Workflow recipes

Complete workflows that combine triggers, data steps, scripts, notifications and integrations — with the reasoning behind each part so you can adapt them.

Each recipe is a full workflow you can rebuild and change. They show the patterns that come up most: importing data without duplicates, messaging many users individually, adding AI to a record, and keeping team data private.

Import customers from another system every night

Goal: keep a customers entity in step with an external CRM, updating existing customers and adding new ones, without creating duplicates when the job runs twice.

Trigger: Timed, 0 2 * * * (02:00 UTC).

Key Step Why
fetch_customers Call an API Pull customers changed in the last day from the CRM
upsert_customers Upsert data Match on the CRM's id, update what changed, create what's new

fetch_customers:

json
{
  "url": "https://api.examplecrm.com/v2/customers?updated_within=24h",
  "method": "GET",
  "headers": { "Authorization": "Bearer {{ $anythink.secrets.crm_api_key }}" },
  "results_path": "customers"
}

upsert_customers:

json
{
  "entity_name": "customers",
  "loop_over": "{{ $anythink.steps.fetch_customers.data }}",
  "payload": "{ \"crm_id\": \"{{ id }}\", \"email\": \"{{ email }}\", \"name\": \"{{ full_name }}\", \"plan\": \"{{ plan }}\" }",
  "match_on": ["crm_id"],
  "on_match": "Update",
  "update_fields": ["email", "name", "plan"]
}

How it works:

  • The API key lives in a project secret and goes in a header, keeping it out of the request URL, which is written to the step log.
  • loop_over runs the payload once per customer, with each customer's fields available by name.
  • match_on: ["crm_id"] makes the import safe to re-run: a customer that already exists is updated, never duplicated.
  • update_fields stops the import overwriting fields your team edits in Anythink.

Adapt it: give crm_id a unique constraint so a manual edit can't create a second match, and add a File handler step if the CRM provides avatars — FetchAndLink with skip_if_empty_source_url: true.

Send each user a personalised reminder

Goal: every morning, remind users who haven't checked in this week, using their own name.

Trigger: Timed, 0 8 * * *.

Key Step Why
due_checkins Read data Find users whose last check-in is more than a week old
remind Send a push notification One notification per user, with their name

due_checkins reads a checkin_status entity filtered on last_checkin_at lt your cut-off date, returning user_id and first_name.

remind:

json
{
  "title": "{{ $anythink.steps.due_checkins.data[*].first_name }}, how was your week?",
  "body": "Your check-in takes two minutes.",
  "click_action": "/check-in",
  "audience": { "kind": "user", "user_id": "{{ $anythink.steps.due_checkins.data[*].user_id }}" }
}

How it works:

  • data[*] in the title and audience makes the push step send once per row, with each row's fields filled in. Up to ten send at a time.
  • When nobody is due, the push step has nothing to send and succeeds with iterations: 0, so a quiet morning isn't a failed job.
  • The step's output totals delivered, failed and unregistered devices, which you can see in Job History. It fails only if every send fails.

Summarise support tickets with Claude

Goal: when a ticket is created, add a two-sentence summary and a suggested priority.

Trigger: Event, EntityCreated on tickets.

Key Step Why
summarise Integration Ask Claude for a summary and a priority, as JSON
parse Run a script Turn Claude's reply into fields
save Update data Write them back to the ticket

summarise:

json
{
  "provider": "claude",
  "operation": "generate-text",
  "credential_source": "system",
  "inputs": {
    "model": "claude-opus-4-8",
    "max_tokens": "1000",
    "system_prompt": "You triage support tickets. Reply with only JSON: {\"summary\": \"two sentences\", \"priority\": \"low\" | \"normal\" | \"urgent\"}",
    "prompt": "Subject: {{ $anythink.trigger.data.subject }}\n\n{{ $anythink.trigger.data.body }}"
  }
}

parse:

javascript
var text = $anythink.steps.summarise.text || "";
var start = text.indexOf("{"), end = text.lastIndexOf("}");
if (start < 0) throw new Error("No JSON in reply: " + text.slice(0, 200));
var reply = JSON.parse(text.slice(start, end + 1));
return { ai_summary: reply.summary, ai_priority: reply.priority };

save:

json
{
  "entity_name": "tickets",
  "ids": "{{ $anythink.trigger.id }}",
  "payload": "{{ $anythink.steps.parse.data[0] }}"
}

How it works:

  • The Integration step returns Claude's reply at the top level, so the script reads $anythink.steps.summarise.text.
  • Parsing in a script means a malformed reply fails the job visibly instead of writing bad data.
  • The whole payload of save is one template, so the script's object is written as-is.
  • save updates by $anythink.trigger.id because trigger.data on a create doesn't include the new id.

Note: Use claude-opus-4-8 for now. Models that begin their reply with a thinking block, including Claude Opus 5, currently return empty text through the Integration step.

Share records with a user's team

Goal: when a coach logs a session note for an athlete, the athlete and everyone in their team can read it, but other teams can't.

Trigger: Event, EntityCreated on session_logs.

One Create data step writes the shared note with row-level security set from the athlete:

json
{
  "entity_name": "session_notes",
  "payload": "{ \"user_id\": \"{{ $anythink.trigger.data.athlete_id }}\", \"note\": \"{{ $anythink.trigger.data.summary }}\" }",
  "auto_set_rls": true,
  "rls_user_field": "user_id",
  "auto_set_rls_user_groups": true,
  "rls_user_group_mode": "all",
  "rls_user_groups_readonly": true,
  "rls_user_group_grant": true,
  "rls_user_group_field": "team_group_id"
}

How it works:

  • Workflow writes have no owner, so without grants nobody but project administrators could read the note.
  • auto_set_rls with rls_user_field gives the athlete read and write access.
  • auto_set_rls_user_groups looks up the athlete's groups; rls_user_group_grant shares the note with every member, read-only.
  • rls_user_group_field stamps the team's group id on the note, so you can filter and report by team.

Adapt it: leave rls_user_group_grant off when only staff should see team records; staff can still read them through group-scoped reads without every team member seeing each other's notes. See Roles and permissions.

Next steps

Step types