Flows (automation)

Also known as: email automation, automated campaigns, drip campaigns, customer journey, workflows, sequences. The /flow page in the console is where you build these.

Flows are Aigeon's automation engine. A flow is a graph: a Trigger brings a contact into the graph, nodes decide what happens next, edges wire everything together, and an Exit node removes the contact from the flow.

When to use a flow vs. a campaign

  • Flow — repeating, per-contact behavior driven by what a contact does or is. Welcome series, drip courses, win-back, lead nurture, transactional.
  • Campaign — a single broadcast on a schedule you pick. Newsletters, product announcements.

Building a flow

Open Flows → Create flow. The editor has three panels:

  • Left — palette of node types you can drag onto the canvas.
  • Center — the graph. Zoom, pan, drag nodes around.
  • Right — settings for whatever node is currently selected.

A valid flow starts at a single Trigger, has at least one terminal Exit node, and every node in between is reachable.

Node types

Six node types appear in the palette. Three of them (Timer, Condition, Task) accept a small expression in their code field — that expression is what makes flows programmable.

| Type | Category | Code field? | What it does | | --- | --- | --- | --- | | Trigger | Entry | no | The single entry point. Enrolls a contact when an event fires (list signup, segment match, custom API event). | | Email | Action | no | Renders a template with the contact's properties and sends it. Tracks opens/clicks/unsubscribes per node. | | Timer | Delay | yes | Pauses the contact. The code returns a future Unix timestamp; the contact resumes at that time. | | Condition | Branch | yes | Evaluates the code as a boolean. Routes the contact down the true edge or the false edge. | | Task | Action | yes | Runs a side-effect (HTTP call, AI job, property write). Can be blocking (wait for completion) or fire-and-forget. | | Exit | Terminal | no | Removes the contact from the flow. A flow can have multiple Exit nodes. |

Timer node

Holds the contact until the code resolves to a future timestamp (or to True for boolean-style helpers). Most timers look like one of:

wait_for(days=1)                       # resume 24h from now
next_day(8, 30)                        # resume tomorrow at 08:30 in the contact's timezone
days_of_week([1,2,3,4,5], 9, 0)        # next weekday at 09:00
weekly_random(17)                      # deterministic per-contact weekly slot

Condition node

Evaluates the code and routes the contact to the True output if it's truthy, otherwise to the False output. Typical patterns:

opened_any_email(7)                    # engaged in last 7 days?
clicked_any_email(3)                   # clicked in last 3 days?
get_user_property('plan_tier') == 'pro'
email_open_rate('node_abc', hour=24) > 0.2
ab_bucket(2) == 0                      # 50/50 split, stable per contact
random() < 0.1                         # random 10% split

Task node

Runs an action. Three knobs on the right-hand panel:

  • Wait until done (is_blocking) — if on, the contact stays on the node until the code returns a non-retry result.
  • Time out (retry_timeout, minutes) — if blocking and the task hasn't completed by this timeout, the contact proceeds anyway.
  • Effective time (time_to_live, minutes) — how long the task's output is cached / considered valid.

Common task bodies:

ai_task("Has the 2026 World Cup final happened yet?")  # polls an AI+web-search agent until it answers
generate_nail_data("subletter_123")                    # fetches personalized content from the Nail backend
set_user_property('last_task_run', now_ts())           # write back onto the contact

Flow code — supported functions

The code field uses a sandboxed Python eval. A shared context dict is always in scope. Each node's code must be a single expression (no import, no multi-line statements). Below is the full set of callable functions.

Context and contact properties

The context carries everything the runtime knows about the contact at the moment the node fires.

| Function | Returns | Purpose | | --- | --- | --- | | get_user_property(key) | any | Read a field off the contact/context (e.g. plan_tier, city, custom property). | | set_user_property(key, value) | context | Write a field back onto the context so downstream nodes can see it. | | get_context(field) | any | Read any top-level context field (meta, timezone, trigger payload, …). |

Fields the runtime populates automatically on every context: hashed_email, plain_email, organization_id, flow_id, timezone, start_time (when the contact entered the flow), last_open, last_click, last_sent, and any fields you passed in via the trigger API.

Time helpers — "what time is it?"

All times are computed in the contact's timezone (falls back to America/Los_Angeles).

| Function | Returns | Purpose | | --- | --- | --- | | now() | datetime | Localized current time. | | now_ts() | int | Current time as a Unix timestamp. | | within_skip_time(hour_begin, hour_end) | bool | True if the current hour is inside [begin, end] — use in Condition nodes to dodge quiet hours. |

Scheduling helpers — "when should the contact resume?"

These return a future Unix timestamp and are meant for Timer nodes.

| Function | Purpose | | --- | --- | | wait_for(days=0, hours=0, minutes=0) | Fixed offset from now. Negative values go backwards (rarely useful outside tests). | | next_day(hour, minute=0) | Tomorrow at hour:minute in the contact's timezone. Handles DST correctly. | | next_week(weekday, hour, minute=0) | Next week, weekday is 0=Sun … 6=Sat. | | days_of_week(weekdays, hour, minute=0) | Next occurrence of any day in the list weekdays (0=Sun … 6=Sat) at hour:minute. Use [1,2,3,4,5] for "next weekday". | | weekly_random(hour, minute=0) | Picks a stable weekday for this contact (hashed from their email + flow id) at hour:minute. Good for spreading a weekly send across the week without jitter per contact. | | monthly_random(hour, minute=0) | Same idea, but picks a stable day-of-month. | | warmup_random(hour, minute=0) | Returns a timestamp between now and now + hour:minute. Use during domain warmup to smear sends across a window. |

Engagement history checks

All of these look at events Aigeon has recorded for the contact and return a boolean — so they belong in Condition nodes.

| Function | Purpose | | --- | --- | | opened_any_email(days, subject_key=None) | True if the contact opened any email in the last days. Pass subject_key to match only subjects containing that substring. | | clicked_any_email(days) | True if the contact clicked any email in the last days. | | sent_any_email(days) | True if Aigeon sent the contact any email in the last days. Use to enforce frequency caps. | | started_within(days) | True if the contact entered this flow within the last days. | | unsub_in_redis(flow_id) | True if the contact has unsubscribed from this flow. Usually wired automatically — you rarely call it by hand. |

Per-node metrics (ab-testing on live performance)

Both of these aggregate across all contacts who have passed through a specific Email node in the given window. Use them in a Condition node to, for example, fall back to a different email if the first one is performing badly.

| Function | Returns | Purpose | | --- | --- | --- | | email_open_rate(email_node_id, hour=1) | float 0–1 | Open rate for the referenced Email node in the last hour hours. | | email_click_rate(email_node_id, hour=1) | float 0–1 | Click-through rate (clicks ÷ opens) for the referenced Email node in the last hour hours. |

Copy a node's id from its context menu in the editor.

Bucketing and randomness

| Function | Purpose | | --- | --- | | random(min=0, max=1) | Uniform random float. Not stable — re-evaluated every call. Good for "send this to 10% of traffic right now", bad for A/B tests that need to be stable per contact. | | ab_bucket(num_bucket) | Returns 0 … num_bucket-1, stable per (contact, flow). Use this for real A/B tests: ab_bucket(2) == 0 always routes the same contact to the same branch. | | hashed_email_mod(context, mod) | Lower-level version of ab_bucket: hashes email XOR flow-id, mods by mod. | | hashed_email_mod_without_salt(mod) | Hashes email only — not flow-scoped, so the same contact will bucket identically across every flow. Use only when that's what you want. |

Task-only helpers (for Task nodes)

These do real work (network calls, Redis writes). Don't call them from Condition or Timer nodes — those are evaluated on the hot path.

| Function | Purpose | | --- | --- | | ai_task(prompt, mcp_servers=[]) | Dispatches prompt to the MCP agent (web-search + Google light search are added by default). Returns retry=True until the agent answers with a firm result, at which point the HTML answer is cached under ai_task_result:{hashed_email}:{flow_id} for 1 hour. Pair with a blocking Task node. | | generate_data(fetch_url, check_url=None) | Generic two-phase HTTP fetch: GET check_url first (expects {"ready": true}), then POST to fetch_url with the context as the body. | | generate_nail_data(subletter_id, debug=False) | Pulls subletter content from the Nail backend with Redis-based deduplication and lock/version control. Uses the Task node's time_to_live and retry_timeout. | | get_user_crime_radar_cdd() | Domain-specific helper that looks up fresh Crime Radar docs for a contact's zipcode. Side-effects: writes doc_id back into context and marks the doc as sent for 12h. |

Operators you can use

Standard Python operators are available inside a code field: + - * / %, comparison (== != < <= > >=), logical (and or not), membership (in), and method calls on return values (e.g. get_context('meta').get('a') == '1'). What you cannot do: import, define functions, assign variables, or run multi-line code.

Worked examples

# Condition: only send if the contact has been quiet for a week but is still recent
not opened_any_email(7) and started_within(30)

# Condition: stable 70/30 split per contact
ab_bucket(10) < 7

# Timer: resume next Monday 9am, but skip if they already got an email in the last 24h
sent_any_email(1) if False else days_of_week([1], 9)

# Condition: fall back to template B if template A is underperforming
email_open_rate('node_promo_v1', hour=24) < 0.1

# Task: write a computed property for downstream nodes to read
set_user_property('score', get_user_property('signups') * 10)

Publishing

Flows have a draft → active → inactive status. While a flow is in draft, you can edit it freely. Setting it to active starts enrolling new contacts the moment a trigger fires. Inactive flows don't enroll anyone new but continue processing contacts already inside them.

Every code field is validated before you can activate — the editor calls the same safe_eval pathway the runtime uses, so syntax errors surface as red borders on the node.

Versioning

Saving a flow creates a new edit log entry. Open Edit history to see a timeline of who changed what; click a version to preview the graph as it was then.

Triggering via API

For bespoke triggers (e.g. a checkout completed), use the public endpoint:

curl -X POST https://api.aigeon.ai/public/api/v1/flow/send-email-through-flow \
  -H "X-Api-Token: sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "flow_id": "flw_abc",
    "contact_email": "alice@example.com",
    "context": { "order_id": "ord_123", "total_usd": 49.00 }
  }'

Everything you pass in context is readable from any code field via get_context('order_id') and from templates via {{context.order_id}}. Requires the sending_access permission.

© Aigeon.ai 2025
All Rights Reserved