Skip to content

AI Workforce

See also: AI Workforce Business Process Runbooks — visual process maps for the run lifecycle, review queue decisions, delivery routing, autonomy promotion, failure recovery, and operator cadence.

The AI Workforce is an in-house agentic AI employee team that runs autonomously on schedule, produces reviewable artifacts, and delivers approved outputs to their destinations. It reduces 8-12 hours/week of repetitive operational work (content drafting, SEO audits, email design, analytics reporting, support response drafting) while maintaining brand-voice fidelity through mandatory post-filters and human oversight.

Architecture

The system has three layers:

  1. Rails Platform (rails_api/) — Data models, GraphQL API, approval workflow, delivery automation, background jobs
  2. Agent Runner (agent_runner/) — Standalone TypeScript Cloud Run service using Claude Agent SDK that executes employee runs
  3. Admin Dashboard (admin_dashboard/) — Operator UI for managing employees, reviewing artifacts, and monitoring costs

Data Flow

Schedule tick (Crono) → ScheduleRunsJob → TriggerRun interaction
  → ExecuteRunJob → AgentRunnerClient dispatches to Agent Runner
    → Agent Runner loads skills + memory + MCP tools
    → query() loop with Claude Agent SDK
    → Brand-voice post-filter (second query() call)
    → Webhook POST back to Rails with artifact
  → ProcessWebhookResult creates AiArtifact + updates memory
  → Operator reviews in admin dashboard
  → ApproveArtifact → DeliverArtifactJob → destination (GitHub PR / Mailtrap / Slack / docs/ / Buffer)

Employees

Seven employees are configured in shadow mode (all outputs require human approval):

EmployeeRole KeySkillsMCP/ToolsScheduleOutput
Coricontent_draftercrafting-page-messaging, framing-release-stories, tightening-brand-voiceGitHub MCPWeekly (Mon 9am)draft_post
Emilifecycle_email_designerdesigning-lifecycle-messages, tightening-brand-voiceMailtrap REST, PostHog RESTBi-weekly (1st/15th 9am)email_template
Scoutseo_auditorinspecting-search-coverage, adding-structured-signalsGitHub MCPWeekly (Mon 9am)code_patch
Dashanalytics_reporterinstrumenting-product-metrics, mapping-conversion-eventsPostHog REST, Sentry RESTWeekly (Mon 7am)report
Allycustomer_support_drafterrefining-prompt-surfaces, tightening-brand-voiceSentry REST, GitHub MCPDaily (7am)recommendation
Veracoach_quality_auditortightening-brand-voice, objectuve-hallmarkNone (data injected via per-run inputs)Daily (7am)report
Remysocial_publisherorchestrating-social-rhythm, planning-editorial-arcs, tightening-brand-voiceNone (data injected via per-run inputs)Mon/Wed/Fri (9am)social_post

Vera — Coach Quality Auditor

Vera samples up to 25 recent CoachInteraction records — stratified per-feature so a single high-volume feature can't crowd out the rest — and produces a concise brand-voice and safety audit report. She is the first XAPP (no-DB) employee: because the Agent Runner has no database access, her working data is assembled by Rails (AiWorkforce::BuildRunInputs) and injected into the run payload at dispatch time rather than fetched by the agent herself.

CoachInteraction::FEATURES lists the 13 coach-voice surfaces Vera audits: coaching, milestones, insights, check_ins, description, welcome_back, compassionate_break, coach_synopsis, goal_draft, goal_suggestions, coach_at_risk_nudge, daily_insight_pack, weekly_digest. A sibling AUDIT_EXEMPT_FEATURES constant (moderation, feedback_summary) lists features that reach Ai::ServiceClient.chat but are deliberately excluded from her sample — moderation is content screening, feedback_summary summarizes user feedback posts for an admin dashboard, and neither is Coach voice a user actually sees. The two constants together must exactly partition AiUsageEvent::FEATURES, enforced by a bidirectional spec (spec/models/coach_interaction_spec.rb) — a new AI feature fails CI until it's consciously classified into one list or the other, rather than silently going unaudited (OBJ-1660; see the sibling AiUsageEvent::FEATURES drift gotcha for the failure pattern this guards against).

Each run, Vera assesses every message in the sample on two dimensions:

  1. Brand voice — Is the Coach "a coach who's also a friend"? Action-forward, brief, empowering, never generic-motivational, never a dark pattern.
  2. Safety — Is any message harmful, clinically/medically overreaching, shaming, or otherwise inappropriate?

Her output is an internal report artifact (internal, never user-facing) with: a one-line overall assessment, a list of flagged messages with severity (low/medium/high), and at most two concrete suggestions to tighten Coach prompts if a pattern is detected. No MCP tools are needed — all data arrives in the run inputs.

Configuration (from seed):

  • schedule_cron: '0 7 * * *' (daily at 7 AM)
  • max_turns: 20
  • autonomy_level: 'shadow'
  • post_filter_skill: nil (output is internal — no brand-voice post-filter needed)
  • output_kind: 'report'

Remy — Social Publisher

Remy drafts scheduled X, LinkedIn, and Facebook posts three times a week (Mon/Wed/Fri, 9am) from aggregate, de-identified sources only — shipped milestone summaries, CHANGELOG highlights, and platform-wide AdminStats counts. Like Vera, she is data-injected (no MCP tools, mcp_servers: []): AiWorkforce::BuildRunInputs#social_publisher_inputs supplies milestone_summary, changelog_entry, and aggregate_stats, explicitly excluding AdminStatsService's per-user recent_activity and monetization supporter_stats fields — no individual user is ever referenced in a public post.

Her output is a social_post artifact with payload keys channels (e.g. ["x", "linkedin", "facebook"]), content (an object keyed by channel with that channel's post text, respecting per-channel limits — X ~280 characters, LinkedIn ~3000 characters, Facebook no hard cap but the same 2-3 sentence brand-voice constraint), and link_url (required whenever facebook is in channels). Every draft passes through the tightening-brand-voice post-filter before landing in the Ops Board's Needs review column, same as Cori/Emi/Ally.

Facebook (link-preview, Route A): Facebook has no natural per-beat landing page, so link_url always points at a canonical CTA anchor from social-media-inventory.md (#features or #connect) — never an invented URL, never an in-app SPA route — tagged with ?utm_source=facebook&utm_medium=social&utm_campaign=remy-social. Facebook/Buffer scrapes og:image/og:title/og:description from that page rather than us generating an image, so every Facebook post's preview card is content-invariant (only the post text varies by beat) — an accepted tradeoff, not a bug. Click-through is measured by filtering the $autocapture / landing-page PostHog events on utm_source = facebook, reviewed on the same weekly cadence as Dash's PostHog pull below. Generated images (AiArtifact.payload.image_urls) attach to X/LinkedIn posts as Buffer assets (Route B); Facebook keeps its link-preview shape because Buffer rejects a post carrying both a link attachment and non-empty assets. Instagram remains deferred — see docs/product/og-image-facebook-scope.md for the full decision record.

social_publisher is one of two AiEmployee::SHADOW_LOCKED_ROLES (alongside Ally's customer_support_drafter) — she can never be promoted above shadow autonomy, enforced at both create and update time so the cap can't be bypassed by seeding a new record at a higher level. See Delivery Automation for how approved social_post artifacts reach Buffer.

Configuration (from seed):

  • schedule_cron: '0 9 * * 1,3,5' (Mon/Wed/Fri at 9 AM)
  • max_turns: 30
  • autonomy_level: 'shadow' (permanently locked — see Autonomy Progression)
  • post_filter_skill: 'tightening-brand-voice'
  • output_kind: 'social_post'

Skills

Each employee loads .claude/skills/{name}/SKILL.md files as system prompt fragments. Skills are baked into the Agent Runner Docker image at build time and define the domain expertise for each role.

MCP and REST Tool Access

  • GitHub — Claude Agent SDK MCP via npx @modelcontextprotocol/server-github (stdio subprocess)
  • PostHog — REST API client (posthog_query tool) for trends, funnels, cohorts
  • Sentry — REST API client (sentry_list_issues, sentry_issue_details tools)
  • Mailtrap — REST API client (mailtrap_list_templates, mailtrap_create_draft tools)
  • Slack — Incoming webhook (slack_post_message tool)
  • Image Gen (image_gen) — REST client (generate_image tool) that calls LiteLLM's OpenAI-compatible POST /v1/images/generations and uploads the result through the existing DesignAsset upload path (POST /webhooks/ai-workforce/assets). Requires the LiteLLM workforce/image route, defined in-repo in infra/litellm/config.{staging,production,local}.yaml (Gemini image model via the existing GOOGLE_API_KEY) and deployed by the deploy-litellm jobs in .github/workflows/staging.yml / production.yml. The docker config.yaml (fake provider) has no image route by design, so locally the tool returns an error string and the run proceeds text-only. Generated URLs land in AiArtifact.payload.image_urls (string[]) and render in the admin Ops Board Focus overlay.

Access is scoped per employee via the mcp_servers column. An employee without a declared server cannot invoke its tools.

Memory System

Each employee has persistent structured memory (JSONB) that carries across runs:

  • last_run_summary — What the employee accomplished in the previous run
  • performance_stats — Token usage, cost, turn count history
  • recent_rejections — Last 5 rejection reasons (operator + auto-rejected), capped FIFO

Memory is serialized as markdown (~2,000 token cap) and injected into the system prompt at the start of each run. After a run completes, ProcessWebhookResult upserts the memory records. When an artifact is rejected (by operator or brand-voice filter), the rejection reason is appended to recent_rejections so the employee avoids repeating mistakes.

Per-Run Inputs Channel

The Agent Runner has no database access. Employees that need dynamic data (such as Vera sampling recent CoachInteraction records) receive it via the inputs field on the POST /runs payload.

How it works:

  1. AgentRunnerClient#build_payload calls AiWorkforce::BuildRunInputs.call(employee:) immediately before dispatching the run.
  2. BuildRunInputs routes by employee.role_key and assembles the appropriate data hash. Any unrecognised role key returns {}.
  3. The Agent Runner receives inputs alongside the employee config. buildUserPrompt appends it as a fenced "Provided data" section in the task prompt so the agent can reference it directly.
  4. An absent or empty inputs hash is a no-op — all existing employees that need no dynamic data are unaffected.

Currently wired:

Role keyData injected
coach_quality_auditor (Vera){ coach_interactions: [...] } — up to 25 CoachInteraction records, stratified per-feature across the 13 audited coach-voice features
social_publisher (Remy){ milestone_summary:, changelog_entry:, aggregate_stats: } — latest shipped milestone summary, top CHANGELOG entry, and whole-platform AdminStats counts (no per-user or monetization fields)

To add a new data-driven employee, add a when branch in AiWorkforce::BuildRunInputs#call and reference inputs keys from the employee's task_prompt.

See agent-runner.md — Per-Run Inputs Channel for the Agent Runner side of this contract.

Brand-Voice Post-Filter

Employees with a post_filter_skill (Cori, Emi, Ally) have their artifacts validated by a second query() call using the tightening-brand-voice skill before surfacing to an operator. This is a separate LLM call — not loaded into the main run's context — to prevent context dilution.

  • Pass — Artifact is created as pending, appearing in the Ops Board's Needs review column
  • Fail — Artifact is created with approval_status: "auto_rejected" and a descriptive reason. It never appears in the operator's pending queue. The rejection reason is stored in the employee's memory.

Approval Workflow

Operators manage artifacts through the admin dashboard's AI Workforce Ops Board (Focus Detail overlay):

  1. Approve — Artifact is marked approved, triggers DeliverArtifactJob
  2. Approve with edits — Operator modifies the payload before approving. Edit distance (Levenshtein ratio) is tracked for performance metrics.
  3. Reject — Requires a reason (minimum 10 characters). Reason is stored in the employee's memory.

Delivery Automation

Approved artifacts are automatically delivered to their destination via DeliverArtifactJob:

Output KindDestinationMethod
draft_post, code_patchGitHub PROctokit (branch + commit + PR)
email_templateMailtrap sandboxREST API (draft, never sent to real recipients)
reportSlack channel + docs/reports/Webhook POST + Octokit commit
recommendationdocs/support-responses/Octokit commit (never auto-sent)
social_postBuffer (X, LinkedIn, Facebook)Ai::SocialPublisherClient GraphQL (api.buffer.com/graphql) — creates a scheduled draft per channel (saveToDraft: true), never immediate-published; a human still confirms in the Buffer UI. Facebook posts carry a linkAttachment (link-preview, Route A) built from the artifact's link_url — see Remy — Social Publisher

Delivery status is tracked on each artifact (delivered_at, delivery_metadata with PR URL, Slack message ID, etc.). For social_post, delivered_at is set only once every requested channel has genuinely posted — Buffer has no idempotency key, so a channel that already succeeded on a prior attempt is never re-submitted on retry (per-channel results persist in delivery_metadata, and only the still-missing channels are retried).

Autonomy Progression

Employees start in shadow mode (all outputs require approval). Promotion is enforced as database validations:

LevelCriteriaEnforcement
shadowDefault for all employees
semi_autonomous>80% approval rate over 4+ weeks, zero rejections in last 2 weeksvalidate :promotion_requirements_met on AiEmployee
autonomous>95% approval rate over 8+ weeks, explicit operator confirmationRequires promotion_confirmed_at timestamp

Restrictions:

  • AiEmployee::SHADOW_LOCKED_ROLES (Ally's customer_support_drafter and Remy's social_publisher) are permanently locked to shadow — one wrong support response or public post does outsized reputational damage. The lock is enforced on both create and update, so it can't be bypassed by seeding a new employee at a higher autonomy level.
  • Demotion is always allowed without criteria checks
  • Operators can manually promote/demote via the admin dashboard

Budget Controls

Each employee has a configurable monthly budget cap (default $30/month):

  • Pre-run enforcementTriggerRun checks month-to-date spend before creating a run. Blocked if budget exceeded.
  • Per-run cap — Agent Runner enforces MAX_RUN_BUDGET_USD (default $1.00) independent of monthly cap.
  • LiteLLM monthly cap — Third enforcement layer at the proxy level.
  • Slack alertsBudgetAlertJob fires at 80% and 100% thresholds (deduplicated via Rails.cache).

Admin Dashboard Views

ViewRoutePurpose
AI Workforce (Live Ops Board)/ai-workforceLanding (v4.8, OBJ-1562). Four-column board (Running / Needs review / Handed to you / Shipped today) over every in-flight and recent work item, plus a live activity rail. Click a ticket to open the Focus Detail overlay for approve/edit/reject/hand-off/pause. 30s auto-refresh. /ops-board and the retired /review-queue redirect here.
Agents/agentsEmployee cards with status, budget bar, next run, last result. Run Now / Pause buttons. 30s auto-refresh.
Employee Detail/ai-workforce/:idTabbed view (Overview / Runs / Memory). Approval rate chart (Chart.js 4-week rolling bar chart). Autonomy promotion controls. Edit configuration modal for skills, MCP tools, task prompt, model, schedule, and budget.
Run Detail/ai-workforce/:employeeId/runs/:runIdStat cards (turns, duration, tokens, cost). Tool call timeline. Artifact list with delivery status.

See Admin Dashboard Guide — AI Workforce (Live Ops Board) for the full board and Focus Detail overlay walkthrough.

Employee config (skills, MCP tool access, task prompt, model, schedule, budget, post-filter skill) is editable from the Employee Detail view via the updateAiEmployee mutation — the change applies to the next run. This is what makes the seeded employees usable without a redeploy. See Admin Dashboard Guide — Employee Detail for the field-by-field walkthrough.

Agent Runner Service

The Agent Runner is a standalone TypeScript Express service deployed on Cloud Run:

  • Package: @anthropic-ai/claude-agent-sdk@0.2.104
  • Port: 4001
  • Endpoints: POST /runs, GET /runs/:id/status, POST /runs/:id/cancel, GET /health
  • Auth: OIDC (production) or shared secret (development)
  • Skills: Baked into Docker image from .claude/skills/
  • State: In-memory runMap for active runs (lost on restart — Rails is source of truth)

Run Lifecycle

  1. Rails ExecuteRunJob dispatches via AgentRunnerClient (Faraday, fire-and-forget)
  2. Agent Runner accepts (202), builds system prompt (preamble + skills + memory)
  3. query() loop runs with MCP tools and REST clients
  4. Brand-voice post-filter (if applicable)
  5. Webhook POST back to Rails with result
  6. ProcessWebhookResult creates artifact, updates memory, records cost

Environment Variables

See rails_api/.env.example for the complete list. AI Workforce-specific variables:

VariableServicePurpose
RAILS_WEBHOOK_SECRETRailsHMAC secret for Agent Runner webhook
AGENT_RUNNER_URLRailsAgent Runner base URL
AGENT_RUNNER_SECRETRailsShared auth secret (dev)
AGENT_RUNNER_AUTH_MODERailssecret or oidc
GITHUB_ACCESS_TOKENRailsGitHub PAT for delivery PRs
GITHUB_TARGET_REPORailsTarget repo for PRs
MAILTRAP_SANDBOX_INBOX_IDRailsSandbox inbox for email delivery
SLACK_WEBHOOK_URLRailsBudget alerts + report delivery
ANTHROPIC_BASE_URLAgent RunnerLiteLLM proxy URL
ANTHROPIC_API_KEYAgent RunnerLiteLLM API key
SKILLS_DIRAgent RunnerPath to skills directory
MAX_RUN_BUDGET_USDAgent RunnerPer-run token budget cap

Local Development

bash
# Start full stack with Agent Runner
docker-compose up --build

# Seed employees
docker-compose exec api rails db:seed

# Trigger a manual run
# Open admin dashboard → AI Workforce → click "Run Now" on any employee

# Or via GraphQL:
# POST /graphql with TriggerAiRun mutation

Last updated: 2026-07-20 (PR #1639, v4.9 Phase 1b, OBJ-1536: Remy drafts Facebook link-preview posts via the Buffer GraphQL client alongside X/LinkedIn, with UTM-tagged link_url click-through measurement — see docs/product/og-image-facebook-scope.md)

Loading…