Arial/docs← Agent view

Arial docs

Arial is product analytics for coding agents. An agent signs itself up with one HTTP call, starts writing events immediately, and hands a claim link to the human so they can take ownership later.

This page is a user manual for getting running — signup, handoff, events, reads. Keep it open while you wire things up.


Set up a project

If you have an existing project (Node.js or otherwise) and want a one-shot bootstrap — workspace + SDK install + config file + env wiring — use arial init:

cd your-project
npx @arial-ai/cli init

That single command:

One workspace can span multiple repos (acme-www, acme-app, acme-api): the first init in an org creates the workspace; subsequent inits in sibling repos adopt it with a different --source. All repos share one write key; the source tag on each event is what tells journeys apart.

{
  "$schema": "https://arial.sh/schema/arial.json",
  "workspace": "w-acme",
  "workspaceId": "wsp_...",
  "source": "marketing",
  "writeKey": "wk_...",
  "linkedDomains": ["app.acme.com"],
  "taxonomyVersion": "0.0.4"
}

Re-running arial init is safe: existing arial.json files are left alone unless you pass --force, and .env.local entries are appended (never overwritten). Pass --workspace <slug> to adopt an existing workspace, --source <tag> to declare this repo's source (marketing | app | backend | your own; default app), --linked-domains app.acme.com on marketing repos to enable cross-domain identity stitching, --new to force a fresh signup, or --no-install to skip the SDK install.

After init, run arial agent-stanza >> AGENTS.md to teach any agent in the repo how to use Arial.

Sign up

If you only need credentials — no project scaffolding, no SDK install — use arial signup directly. This is what arial init calls under the hood:

npx @arial-ai/cli signup --name "my-project"

Or install globally for faster subsequent calls:

npm install -g @arial-ai/cli
arial signup --name "my-project"

--name is optional. Add --json for a machine-readable response envelope. By default the CLI saves the returned agent key to ~/.config/arial/config.json (or $XDG_CONFIG_HOME/arial/config.json; override with ARIAL_CONFIG_DIR) so all subsequent arial commands authenticate automatically (pass --no-save to skip this).

The result is returned once and never again:

{
  "workspace": {
    "id": "wsp_...",
    "slug": "w-...",
    "name": "...",
    "avatarUrl": null,
    "taxonomyVersion": "0.0.4",
    "businessModel": null,
    "vertical": null,
    "companyStage": null,
    "contributesToCorpus": true,
    "plan": "free",
    "createdAt": "...",
    "updatedAt": "..."
  },
  "agentKey":        "agk_...",
  "writeKey":        "wk_...",
  "claimPassphrase": "six-words-joined-by-dashes",
  "claimUrl":        "https://arial.sh/claim#six-words-joined-by-dashes",
  "activeUntil":     "ISO-8601 timestamp, 7 days from signup"
}

Hand off to the human

Send them the claim URL and the passphrase. Something like:

I've set up an Arial workspace so I can set up analytics and
include real user behaviour back into our work. Claim it for free here:

  <claimUrl>

Or paste this at https://arial.sh/claim:

  <claimPassphrase>

The link expires <activeUntil> (7 days).

Claiming is fast: the human signs in with Google, becomes the workspace owner, and sees dashboards and reports. Nothing changes for the agent — your key keeps working.

If nobody claims within 7 days the workspace deactivates. Sign up a new one and ask your human earlier.


Send events

Arial ships an opinionated, fixed event taxonomy. The same names mean the same things across every workspace, which is what makes cross-customer benchmarks and the agent's strategy model work. Read this section before you instrument anything.

The fastest path is @arial-ai/sdk — it batches, retries with backoff, validates against the taxonomy client-side, fills in context.* for you, and works in Node 20+, Bun, Deno, browsers, Cloudflare Workers, and Vercel Edge.

npm install @arial-ai/sdk

import { createArial } from "@arial-ai/sdk"

const arial = createArial({
  writeKey:    "wk_...",    // from signup — safe to embed in browser/mobile
  workspaceId: "wsp_...",   // from signup
  source:      "app",       // this repo's tag from arial.json
  onError:     (err) => console.warn("[arial]", err.code, err.message),
})

arial.identify("user_42", { plan: "pro" })
arial.track("user.signed_in", { method: "google" })
arial.page({ path: "/dashboard", title: "Dashboard" })

// Before a serverless function exits or on app teardown:
await arial.shutdown()

Full reference: https://www.npmjs.com/package/@arial-ai/sdk.

Cross-domain identity stitching

When your marketing site (acme.com) and app (app.acme.com) are different origins, pass linkedDomains on the marketing site's SDK config:

const arial = createArial({
  writeKey: "wk_...",
  workspaceId: "wsp_...",
  source: "marketing",
  linkedDomains: ["app.acme.com"],
})

The SDK installs one global click listener: clicks on links to a linked domain get a short-TTL signed _arial parameter appended carrying the visitor's anonymous_id. The app-side SDK adopts the id on landing and strips the parameter. For programmatic navigation (router pushes, redirects, OAuth round-trips) decorate explicitly:

const url = arial.decorateUrl("https://app.acme.com/signup")

When the visitor later signs up or calls identify(), Arial links the anonymous journey to the user server-side, so funnels read marketing page view → app signup → backend subscription as one actor. Stitching powers analytics funnels only — experiment assignment never depends on it, so experiment math stays trustworthy even if a link is missed. Origins that share a parent domain (.acme.com) also get a parent-domain cookie automatically — no config needed.

The rule

Use canonical events only. The full catalogue is at /docs/taxonomy.json (machine-readable) or /docs/taxonomy.txt (plain-text) — consult it before naming any event. The taxonomy covers the full B2B SaaS lifecycle; if an action isn't in the catalogue, don't track it yet.

Events are validated at ingest against the canonical schemas. Non-canonical event names are rejected with a reason.

Hard limits

Where to look

Raw HTTP (any language, any runtime)

The SDK is the easy path; the endpoint below is stable and documented so anything that speaks HTTP — Python, Go, Rust, curl, a locked-down edge runtime — can integrate directly.

POST https://events.arial.sh/v1/events
Authorization: Bearer wk_...
Content-Type: application/json

Request body — a batch of 1 to 500 event envelopes wrapped in { "events": [...] }:

{
  "events": [
    {
      "event": "user.signed_in",
      "properties": {
        "method": "google"
      },
      "context": {
        "arial_event_id":         "<uuid>",
        "arial_timestamp":        "<iso8601>",
        "arial_taxonomy_version": "0.0.4",
        "arial_sdk_version":      "<string>",
        "arial_workspace_id":     "<must match the workspace your key is bound to>",
        "user_id":                "<string|null>",
        "anonymous_id":           "<string>",
        "session_id":             "<string>",
        "account_id":             "<string|null>",
        "platform":               "web|ios|android|server",
        "source":                 "marketing|app|backend|<your tag>"
      }
    }
  ]
}

Every envelope's context.arial_workspace_id must equal the workspace bound to the write key in the Authorization header — mismatches are rejected per-envelope without failing the rest of the batch.

Response — 202 Accepted — reports per-event results. Invalid envelopes are rejected with a reason; valid events in the same batch are still persisted:

{
  "accepted": 1,
  "rejected": 1,
  "errors": [
    {
      "index": 1,
      "event": "app.launched",
      "reason": "Unknown event name: 'app.launched'. Arial accepts canonical events only — see the taxonomy at https://arial.sh/docs/taxonomy.txt."
    }
  ]
}

Example:

curl -s -X POST https://events.arial.sh/v1/events \
     -H 'authorization: Bearer wk_...' \
     -H 'content-type: application/json' \
     -d '{"events":[{"event":"user.signed_in","properties":{"method":"google"},"context":{ ... }}]}'

POST /v1/identify takes the same Authorization: Bearer wk_... header and a { "user_id", "anonymous_id"?, "account_id"?, "traits"? } body. When anonymous_id is present, Arial records the anonymous→identified link that powers cross-domain funnel stitching. Traits are accepted but not yet persisted (server-side trait store is a follow-up).

Ingest also stamps context.lifecycle_stage on every stored event (anonymous | signed_up | activated | paying), derived from the actor's prior events — SDKs never set it.

Free workspaces have a fair-use cap of 100k events/month; batches over the cap return 429 with code EVENT_CAP_REACHED and resume next month.


Read analytics

Query the events you've sent against a strict, Arial-authored catalogue of metrics, funnels, and reports. Every response is agent-first: alongside the raw series, each payload carries a natural-language interpretation, a suggested_next list of concrete follow-up URLs, and cross-references to related entries in the catalogue.

Control plane: https://api.arial.sh. Authenticate with Authorization: Bearer agk_....

Two ways to read

Catalogue (list what you can query)

GET /v1/metrics    — every metric definition
GET /v1/funnels    — every funnel definition
GET /v1/reports    — every report definition

Each entry carries id, name, description, unit, allowed granularities, allowed segments, and related cross-references. The catalogue is workspace-agnostic — identical for everyone — so an agent can memoise it.

curl -s https://api.arial.sh/v1/metrics \
     -H 'authorization: Bearer agk_...'

Metric detail (compute)

GET /v1/metrics/:id

Query-string parameters (all optional):

Example:

curl -s 'https://api.arial.sh/v1/metrics/dau?from=2026-03-01&to=2026-04-01&granularity=day' \
     -H 'authorization: Bearer agk_...'

Response (scalar shape):

{
  "kind":        "scalar",
  "metric":      "dau",
  "name":        "Daily Active Users",
  "description": "...",
  "unit":        "users",
  "from":        "2026-03-01",
  "to":          "2026-04-01",
  "granularity": "day",
  "segment_by":  null,
  "series":      [{ "date": "2026-03-01", "value": 142 }, ...],
  "summary":     { "mean": 158, "min": 120, "max": 193, "trend_pct": 12.4 },
  "segments":    null,
  "interpretation": "DAU averaged 158 over the last 31 days, trending +12.4%.",
  "suggested_next": [
    { "description": "Break down by platform", "url": "/v1/metrics/dau?segment_by=platform" }
  ],
  "related":     { "metrics": ["wau", "mau"], "funnels": [], "reports": ["core_product_health"] },
  "computed_at": "2026-04-20T12:00:00.000Z"
}

summary.trend_pct compares the later half of the series against the earlier half. It's directional orientation, not a statistical test — re-interpret the series yourself if precision matters.

Invalid granularity or segment_by returns 400 VALIDATION_ERROR with details.allowed listing the valid values and a details.suggestion URL you can retry.

Funnel detail (compute)

GET /v1/funnels/:id

Query-string parameters (all optional):

Funnel segmentation is not yet supported — segment_by is accepted by the schema for forward-compat but returns 400 VALIDATION_ERROR today. Segment the underlying metrics instead.

Example:

curl -s 'https://api.arial.sh/v1/funnels/signup_to_activation?from=2026-01-17&to=2026-04-17' \
     -H 'authorization: Bearer agk_...'

Response is dual-shaped. steps is the aggregate view across the whole window — one entry per declared step, in order, with per-step user counts and conversion ratios. series is the same data broken down into per-bucket cohorts so an agent can spot trends without a second request:

{
  "funnel":      "signup_to_activation",
  "name":        "Signup → Activation",
  "window_days": 7,
  "from":        "2026-01-17",
  "to":          "2026-04-17",
  "granularity": "week",
  "segment_by":  null,
  "steps": [
    { "event": "user.signed_up",        "label": "Signed up",           "users": 1200, "conversion_from_anchor": 1,     "conversion_from_previous": null, "drop_off_from_previous": 0 },
    { "event": "onboarding.completed",  "label": "Finished onboarding", "users":  960, "conversion_from_anchor": 0.8,   "conversion_from_previous": 0.8,  "drop_off_from_previous": 240 },
    { "event": "activation.reached",    "label": "Activated",           "users":  540, "conversion_from_anchor": 0.45,  "conversion_from_previous": 0.56, "drop_off_from_previous": 420 }
  ],
  "series": [
    { "date": "2026-01-19", "step_counts": [100, 80, 45], "status": "ready",   "window_closes_at": null },
    { "date": "2026-04-13", "step_counts": [ 85, 60, 20], "status": "pending", "window_closes_at": "2026-04-27T00:00:00.000Z" }
  ],
  "interpretation": "Signup → Activation — 1,200 users at \"Signed up\" → 45.0% reached \"Activated\" in the last 12 weeks. Largest drop-off: 420 users between \"Finished onboarding\" and \"Activated\".",
  "suggested_next": [ ... ],
  "related":     { "metrics": [...], "funnels": [...], "reports": [...] },
  "computed_at": "2026-04-20T12:00:00.000Z"
}

Each bucket carries a status of ready or pending. A bucket is pending while its observation window is still open — the funnel's window_days past the bucket end — so later steps are a lower bound, not a final reading. window_closes_at tells you when to re-query.

Cohort windows and pending buckets

Cohort-shaped surfaces (activation, retention, funnels) watch for follow-up events beyond each bucket's end. Until that observation window closes, the bucket is censored: more events may still land and move the number. Every per-point payload therefore carries:

The surrounding interpretation string also notes how many recent buckets are still pending, so a human or agent reading the prose sees the caveat without inspecting every point.

What's live, what's coming

Live in v1: metric detail for DAU/WAU/MAU, new sign-ups, sessions per user, error rate, support contact rate, feature adoption rate, onboarding completion rate, activation rate, time-to-activation, and w1/w4/w12 retention (cohort metrics anchor on each user's first user.signed_up event; cohorts whose observation window hasn't fully elapsed are reported as partial data). Funnel detail (GET /v1/funnels/:id) is live for the signup_to_activation and onboarding funnels.

Segmenting by auth_method works across every metric that declares it — DAU/WAU/MAU pick up each user's sign-up method via a join, so the same segment behaves consistently whether the metric is counted at the event level (new_signups) or at the user level.

Coming next: the GET /v1/reports/:id detail endpoint and funnel segmentation. Reports list in the catalogue today but the detail endpoint is not yet wired — calls return VALIDATION_ERROR pointing at what is computable.

Experiments

Experiments are how Arial turns a PR's outcome from a guess into a measurement. The agent registers an experiment when it opens a PR; the analytics SDK assigns each actor a sticky variant; a daily readout joins exposures with the canonical target metric and recommends ship, revert, continue, or "still underpowered."

In M0 the loop is two arms (control + treatment), 50/50 fixed allocation, derived conversions (no experiment.converted event — the readout joins on whatever canonical target metric you declared), and explicit conclusions (the agent decides; the system never auto-ships or auto-reverts).

Register

POST /v1/experiments

Required fields: key (slug, unique per workspace; this is the same string arial.variant(key, …) will receive), hypothesis (one sentence), targetMetric (must be a canonical event name — non-canonical strings are rejected), mde (pre-registered minimum detectable effect, absolute, 0..1).

Optional: surface (product default, or web), alpha (default 0.05), conversionWindowHours (default 168 = 7 days), prUrl, decisionDeadline.

surface declares which side of the anonymous/identified boundary the experiment lives on. product experiments assign by account_id ?? anonymous_id (account-consistent for B2B teammates; pass assignmentUnit: "user_only" for person-level tests); web experiments assign by anonymous_id and resolve for visitors who never sign in — use them for marketing-site tests. An experiment is one or the other, never both, so experiment math never depends on identity stitching.

Free workspaces run 1 experiment at a time — concluding frees the slot. Pro is unlimited.

Example:

curl -s -X POST https://api.arial.sh/v1/experiments \
     -H 'authorization: Bearer agk_...' \
     -H 'content-type: application/json' \
     -d '{
       "key": "checkout-cta-copy",
       "hypothesis": "Imperative copy lifts checkout completion",
       "targetMetric": "checkout.completed",
       "mde": 0.05,
       "prUrl": "https://github.com/acme/app/pull/42"
     }'

The experiment is created in running status. The flag-eval service (flags.arial.sh) propagates it to the analytics SDK on its next config poll (~60s). See [Flag config](#flag-config) for what the SDK polls under the hood.

Wrap your code

In your app, with @arial-ai/sdk:

const variant = arial.variant("checkout-cta-copy", { fallback: "control" })
if (variant === "treatment") {
  // new code path
} else {
  // existing code path
}

The SDK auto-emits experiment.exposed on evaluation and injects context.experiment_assignments into every subsequent event so attribution is a filter, not a join.

Read

GET /v1/experiments                  — list every experiment in the workspace
GET /v1/experiments/:key             — one experiment + its latest readout inline
GET /v1/experiments/:key/readout     — latest readout only (poll-friendly)
GET /v1/experiments/:key/snapshot    — point-in-time stats without changing the experiment

The readout payload carries per-variant exposure and conversion counts, control vs treatment rates, absolute and relative lift, a 95% Newcombe-Wilson CI, p-value (pooled-variance two-proportion z-test), significant, and a recommendation of ship, revert, continue_running, or underpowered. Recommendations are advisory — concluding is always explicit.

The snapshot payload has the same core stats plus source, isFinal, observedLeader, and confidenceLabel. For running experiments, snapshots compute from live events at request time and are never persisted. For concluded experiments, snapshots prefer the latest persisted readout so the final view matches the official result.

Conclude

POST /v1/experiments/:key/conclude

Body: { "decision": "ship" | "revert", "note"?: "..." }. One-way: a concluded experiment cannot be flipped — open a new experiment with a different key to revisit.

Concluding computes one final readout and (when the workspace contributes to the data co-op) writes one anonymous labelled row — (team context, change, target metric, measured outcome) — to the shared corpus that powers benchmarks and the idea library. Aggregates only; never raw, never sold.

Surfaces

Coming next


Qualify

Three questions tune your workspace's benchmarks: business model (B2B/B2C), vertical, and company stage. Because the answers select which cohort you are compared against, accurate answers give you accurate answers — there is no incentive to game them.

PATCH /v1/workspaces/:slug/qualification
{ "businessModel": "b2b", "vertical": "devtools", "companyStage": "seed" }

Partial bodies are fine — an agent typically pre-fills what it can infer from the repo and marketing copy, then asks the human to confirm. CLI: arial workspace qualify --business-model b2b --vertical devtools --stage seed. Also editable in the dashboard under Settings.

Benchmarks

"Is 22% activation good?" Benchmarks answer with a percentile against teams like yours.

GET /v1/benchmarks            — catalogue of benchmarkable metrics
GET /v1/benchmarks/:metric    — your value + percentile vs your cohort

{
  "metric":     "activation_rate",
  "value":      0.22,
  "cohort":     { "businessModel": "b2b" },
  "percentile": 24,
  "source":     "live",
  "n":          12,
  "distribution": { "p10": 0.08, "p25": 0.14, "p50": 0.22, "p75": 0.32, "p90": 0.45 },
  "computedAt": "..."
}

CLI: arial benchmark list, arial benchmark get activation_rate --json.

Ideas

"So what do I try?" The idea library is a ranked catalogue of growth experiments, each tagged to the canonical metric it moves and backed by evidence — hand-curated classics at first, then real measured outcomes as customer experiments conclude.

GET  /v1/ideas?weak_metric=activation.reached   — ranked list, weakest metric first
GET  /v1/ideas/:id
POST /v1/ideas/:id/accept                       — body: { "resultingExperimentKey"? }

Each idea carries title, hypothesis, targetMetric, featureRole, expectedLift, evidence ({ n, source: "curated" | "learned" }), and an implementationHint concrete enough to act on.

Accepting an idea records that you're acting on it; pass the experiment key you registered so the experiment's concluded outcome flows back into the idea's evidence — that's the flywheel that makes the library smarter for everyone.

Quota: Free workspaces receive 3 new ideas per calendar month (re-reading an already-delivered idea is always free); Pro is unlimited. Exhausted quota returns 429 QUOTA_EXCEEDED with the quota state in details. Like benchmarks, ideas require co-op membership.

CLI: arial idea list --weak-metric activation.reached --json, arial idea get <id>, arial idea accept <id> --experiment <key>.

The data co-op

Benchmarks and ideas are powered by a shared corpus: every concluded experiment writes one anonymous labelled row — (team context, change, target metric, measured outcome). Contributing is the default and is what makes you eligible to receive benchmarks and ideas: contribution and receipt are the same membership.


Events tail

Verification surface, not a query API. After firing events, hit this to confirm the payload landed with the shape you expected — independent of whether any metric has started reporting.

GET /v1/events?limit=50

limit is optional (default 50, max 200). Ordered by ingest time (received_at) descending, scoped to your workspace.

{
  "events": [
    {
      "id":           "evt_...",
      "event":        "user.signed_up",
      "received_at":  "2026-04-19T12:00:01.000Z",
      "timestamp":    "2026-04-19T12:00:00.000Z",
      "user_id":      "user_1",
      "anonymous_id": "anon_...",
      "session_id":   "sess_...",
      "account_id":   null,
      "source":       "app",
      "properties":   { "method": "google" }
    }
  ]
}

received_at is server receive time; timestamp is client-claimed event time — they differ when the SDK backfills or clocks drift. Use received_at to answer "did my fire just land?"

Surfaces: arial events list [--limit N] (CLI), client.events.list({ limit? }) (internal typed client, @workspace/client).


Flag config

The analytics SDK keeps arial.variant() fast and deterministic by polling a small, public endpoint for your workspace's running experiments. You almost never talk to this endpoint directly — @arial-ai/sdk does it for you — but the surface is documented so you can implement your own SDK or debug in a pinch.

GET https://flags.arial.sh/v1/flags

Authentication: Authorization: Bearer wk_... (the write key, same one you use for event ingest). The agent key is rejected — the flags service is write-key-only so it's safe to reach from browsers and mobile apps.

Caching: the response includes a strong ETag header. Send If-None-Match: "<etag>" and you'll get 304 Not Modified with no body. The SDK polls every ~60s, so the common case is a near-empty round trip.

Response:

{
  "workspaceId": "wsp_...",
  "etag":        "sha256:...",
  "experiments": [
    {
      "key":             "checkout-cta-copy",
      "surface":         "product",
      "assignmentUnit":  "account_or_anonymous",
      "variants": [
        { "variantId": "control",   "weight": 50 },
        { "variantId": "treatment", "weight": 50 }
      ]
    }
  ]
}

Only running experiments appear. Concluded ones are dropped so a stale SDK never re-emits experiment.exposed for a decided experiment. Weights are integers; the SDK normalises and hashes the actor id to pick a deterministic variant: anonymous_id for anonymous_only (web experiments), account_id ?? anonymous_id for account_or_anonymous, user_id for user_only (unidentified visitors get the fallback).

Errors follow the standard envelope. A 401 means the write key is invalid or revoked; a 5xx is transient — the SDK keeps serving the last cached body until the next poll succeeds.


Reference

Two services, two base URLs:

All versioned endpoints live under /v1/*.

Auth headers:

POST /v1/workspaces/signup and GET /v1/schema are unauthenticated by design — signup mints the first credential, and the schema endpoint is the discovery surface an agent can reach before it has one. Everything else under /v1/* returns 401 without a recognised credential.

GET /v1/schema returns a machine-readable self-description of the control plane: every endpoint, its summary, its auth mode, and links to docs, taxonomy, and the ingest and flags services. One URL, the whole surface — useful when an agent lands on Arial through a search result and needs to orient without reading this page.

GET /v1/me returns the authenticated user. It is user-session-only: authenticate with the arial_token session cookie (web flow) — agent keys are rejected.

Error envelope:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "...",
    "details": null
  }
}

Common codes: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN (data-co-op opt-out — benchmarks/ideas unavailable), 404 NOT_FOUND, 409 CONFLICT, 429 QUOTA_EXCEEDED (Free-plan quota; details carry the quota state), 500 INTERNAL.

Versioning: breaking changes get a new URL prefix (/v2/*). Within a version the response is additive only.


Help

Questions, feedback, security reports: hello@arial.sh.