Skip to main content
The Action Runner is the bridge between what a 7B language model can reliably produce and what a production automation system actually needs. This page explains why it exists, how it works end-to-end, and what each of its 20 step types does.

Why it exists

Qwen 2.5 7B and Dolphin 3 8B cannot reliably produce the structured JSON function-call format that AnythingLLM’s native tool-calling pipeline expects. Under load, the formatting breaks. Tool calls get dropped. JSON is malformed.
Rather than fight the model’s limitations, GenieHelper routes around them. Instead of asking the model to call a tool directly, the system asks it to emit a plain-text ACTION tag:
[ACTION:scrape-profile:{"platform":"onlyfans","user_id":"123"}]
This is something 7B models do reliably — append a structured string to a response. The Action Runner intercepts that string, parses it, fetches the corresponding flow definition from the action_flows Directus collection, and executes the steps deterministically without involving the LLM again. Result: Reliable automation from an uncensored 7B model with no cloud dependency.

Architecture

Agent response stream

    ├── Plain text (sent to user)

    └── [ACTION:slug:{"params"}] ──► Action Runner interceptor


                                 Fetch flow from action_flows
                                 (Directus collection)


                                 Execute steps sequentially
                                 (20 step types, 120s timeout each)


                                 Return { success, results }
                                 to genieChat SSE stream
Source: server/utils/actionRunner/index.js · server/utils/actionRunner/stepExecutors.js The action bus integration lives in server/utils/actionBus/ and handles the BullMQ enqueue side.

ACTION tag format

The format is rigid and parseable by a simple regex — no LLM is involved in parsing:
[ACTION:<slug>:<json-params>]
PartDescription
slugUnique identifier of a flow in the action_flows Directus collection
json-paramsJSON object — the input parameters passed to the flow’s first step

Examples

[ACTION:scrape-profile:{"platform":"onlyfans","user_id":"123"}]

[ACTION:taxonomy-tag:{"content_id":"abc-456","text":"beach yoga selfie"}]

[ACTION:post-create:{"draft_id":"789","platform":"fansly","scheduled_at":"2026-03-21T14:00:00Z"}]

[ACTION:message-generate:{"fan_id":"fan-001","context":"subscriber renewal"}]

[ACTION:media-process:{"file_id":"media-999","operation":"watermark"}]

[ACTION:memory-recall:{"task":"scrape onlyfans stats","top_n":6}]

Flow definitions — the action_flows collection

Flows are stored as records in the action_flows Directus collection. Each record contains:
FieldTypeDescription
slugstring (unique)The identifier used in ACTION tags
namestringHuman-readable label for status messages
activebooleanDisabled flows are rejected immediately
stepsJSON arrayOrdered list of step definitions
Each step in the steps array has:
{
  "type": "directus_read",
  "result_key": "profile",
  "config": {
    "collection": "creator_profiles",
    "filter": { "id": { "_eq": "$input.creator_profile_id" }
    }
  }
}
The result_key value becomes available to subsequent steps as $result_key.field_name.

Seeded flows

The following flows are seeded in the action_flows collection at install time:
SlugPurpose
scout-analyzeAnalyse a creator’s platform presence and surface insights
taxonomy-tagRun LLM-assisted taxonomy tagging on a piece of content
post-createCreate and optionally schedule a content post
message-generateDraft a fan message using the creator’s persona
memory-recallActivate relevant skills via JIT hydration
media-processEnqueue a media job (watermark, clip, thumbnail)

The 20 step types

Step executors live in server/utils/actionRunner/stepExecutors.js. Each executor receives a config object (with interpolated variables) and an AbortSignal for the 120-second timeout.
Reads one or many items from a Directus collection.
{
  "type": "directus_read",
  "config": {
    "collection": "creator_profiles",
    "filter": { "id": { "_eq": "$input.creator_id" } },
    "fields": "id,display_name,platform_connections",
    "limit": 1
  }
}
Supports both single-item (id param) and list queries with filter, sort, fields, and limit.
Creates a new record in a Directus collection.
{
  "type": "directus_write",
  "config": {
    "collection": "media_jobs",
    "item": {
      "operation": "apply_watermark",
      "file_id": "$input.file_id",
      "status": "queued"
    }
  }
}
Updates a single item by ID.
{
  "type": "directus_update",
  "config": {
    "collection": "media_jobs",
    "id": "$job.id",
    "item": { "status": "processing" }
  }
}
Deletes a single item from a collection.
{
  "type": "directus_delete",
  "config": {
    "collection": "hitl_sessions",
    "id": "$input.session_id"
  }
}
Triggers a Directus webhook flow. Note: the Directus request operation is known-broken (returns {}). This step triggers flows via their webhook endpoint instead.
{
  "type": "directus_trigger",
  "config": {
    "flow_uuid": "7506f825-...",
    "payload": { "content_id": "$input.content_id" }
  }
}
Known flow UUIDs: 591a8845 (memory-sync) · 7506f825 (taxonomy-tag) · 50a4dc14 (post-create) · a4f083fa (message-generate).
Starts a headless Playwright session via the Stagehand server and returns a sessionId.
{
  "type": "stagehand_start",
  "result_key": "browser",
  "config": {}
}
All subsequent Stagehand steps require $browser.sessionId.
Navigates the browser session to a URL.
{
  "type": "stagehand_navigate",
  "config": {
    "sessionId": "$browser.sessionId",
    "url": "https://onlyfans.com/my-creator-handle"
  }
}
Executes a natural language action against the current page (click, type, scroll, select).
{
  "type": "stagehand_act",
  "config": {
    "sessionId": "$browser.sessionId",
    "action": "click the Stats tab"
  }
}
Extracts structured data from the current page using a natural language instruction and optional JSON schema.
{
  "type": "stagehand_extract",
  "result_key": "stats",
  "config": {
    "sessionId": "$browser.sessionId",
    "instruction": "Extract subscriber count, total earnings, and post count",
    "schema": {
      "subscribers": "number",
      "total_earnings": "string",
      "post_count": "number"
    }
  }
}
Closes and cleans up a Stagehand browser session.
{
  "type": "stagehand_close",
  "config": { "sessionId": "$browser.sessionId" }
}
Like stagehand_extract but checks extractCache first. Returns the cached result if the (sessionId, instruction) pair was recently extracted, otherwise performs a live extraction and caches the result.
{
  "type": "stagehand_extract_cached",
  "result_key": "stats",
  "config": {
    "sessionId": "$session.sessionId",
    "instruction": "Extract subscriber count and total earnings",
    "schema": { "subscribers": "number", "earnings": "string" }
  }
}
Runs a multi-turn chat completion through Ollama. Use this instead of ollama_generate when conversation history matters.
{
  "type": "ollama_chat",
  "result_key": "reply",
  "config": {
    "model": "dolphin-mistral:7b",
    "messages": [
      { "role": "system", "content": "Write in the creator's voice." },
      { "role": "user", "content": "$input.fan_message" }
    ]
  }
}
Reads taxonomy terms for a given platform from taxonomyCache. Faster than directus_read on taxonomy_mapping for hot-path flows because it bypasses Directus and reads the in-process cache.
{
  "type": "taxonomy_terms_read",
  "result_key": "terms",
  "config": { "platform": "onlyfans" }
}
Reads platform-to-taxonomy mapping for a given platform from taxonomyCache.
{
  "type": "taxonomy_platform_maps_read",
  "result_key": "maps",
  "config": { "platform": "fansly" }
}
Invalidates the in-process taxonomy cache. Call this after a taxonomy graph rebuild or bulk ingest to force the next request to re-read from Directus.
{
  "type": "taxonomy_invalidate",
  "config": {}
}
Runs a single-turn prompt through Ollama. Used for sub-tasks within a flow that need LLM output without a full agent session.
{
  "type": "ollama_generate",
  "result_key": "caption",
  "config": {
    "model": "dolphin-mistral:7b",
    "system": "Write in the creator's voice. Style: playful. No hashtags.",
    "prompt": "Write a caption for a beach selfie. Stats: $stats"
  }
}
Enqueues a job to the media-jobs BullMQ queue. The media-worker consumer picks it up and executes FFmpeg, ImageMagick, or Stagehand operations.
{
  "type": "bullmq_enqueue",
  "config": {
    "queue": "media-jobs",
    "job": {
      "type": "apply_watermark",
      "file_id": "$input.file_id",
      "creator_profile_id": "$input.creator_id"
    }
  }
}
The executor returns { queued: true, bullId, jobId }. Use media.job-status via MCP to poll completion.
Makes an arbitrary HTTP request. Used for webhooks, internal API calls, and third-party integrations not covered by the other step types.
{
  "type": "http_request",
  "config": {
    "url": "http://127.0.0.1:3001/api/some-internal-endpoint",
    "method": "POST",
    "body": { "user_id": "$context.user_id" }
  }
}

Variable interpolation

Step configs support variable substitution using $ prefixes. Interpolation is resolved at execution time, just before each step runs.
Variable syntaxResolves to
$input.fieldA field from the ACTION tag’s JSON params
$steps[N].fieldThe result of step N (zero-indexed)
$result_key.fieldThe named result of a previous step
$context.fieldExtra context injected by genieChat (user info, workspace)
// Step 1 result_key = "profile"
// Step 2 can reference it as:
{ "creator_id": "$profile.id" }

End-to-end example: “scrape my OnlyFans stats”

A creator sends this message in the chat interface. Here is what happens:
1

Agent emits ACTION tag

The Dolphin 3 8B orchestrator model processes the request and appends an ACTION tag to its response:
I'll scrape your OnlyFans stats now.

[ACTION:scrape-profile:{"platform":"onlyfans","creator_profile_id":"cp-123"}]
2

genieChat intercepts the tag

The streaming response is scanned for the [ACTION:...] pattern. When found, the tag is extracted and passed to ActionFlowExecutor.run("scrape-profile", { platform: "onlyfans", creator_profile_id: "cp-123" }).
3

Flow definition fetched from Directus

The executor queries action_flows for slug = "scrape-profile". It retrieves a 5-step flow definition: read credentials → start browser → inject cookies → navigate → extract stats.
4

Steps execute sequentially

Each step runs with a 120-second timeout:
  1. directus_read — fetches the platform_sessions record for this creator
  2. stagehand_start — launches a headless browser, returns sessionId
  3. stagehand_navigate — navigates to https://onlyfans.com/my-handle/stats
  4. stagehand_extract — extracts subscriber count, earnings, post count as JSON
  5. directus_write — writes extracted stats to platform_stats_snapshots
5

BullMQ job enqueued

A final bullmq_enqueue step creates a scrape_post_performance job so individual post metrics are fetched asynchronously without blocking the chat response.
6

SSE stream updates the UI

As each step completes, onStatus callbacks emit SSE events that update the TopCueRail mission label and open the live-scrape-feed panel in the Right Wing automatically.

Error handling

  • Each step has a 120-second hard timeout enforced via AbortController.
  • If any step fails, the executor returns { success: false, failedStep: N, error: "..." } immediately — subsequent steps do not run.
  • Timeout and failure messages are surfaced via onStatus to the SSE stream, so the creator sees a meaningful error rather than a stalled spinner.
  • Disabled flows (active: false) are rejected before any steps run.
The Action Runner authenticates to Directus using the admin credentials in DIRECTUS_EMAIL / DIRECTUS_PASSWORD. These are not the creator’s credentials. The user_id context injected by genieChat.js must come from the validated session — never from the LLM’s output.

Build docs developers (and LLMs) love