# A/B test your landing pages Source: https://docs.erdo.ai/ab-testing-pages Split live traffic across page variants, keep each visitor on their variant, attribute every lead, and let the loop decide the winner. # A/B test your landing pages A landing-page A/B test is an [Experiment](/experiments) whose variants are [Pages](/pages). You point one ad at one URL; Erdo serves different versions of that page to different visitors, keeps each visitor on the version they first saw, tags every lead and analytics event with the version that produced it, and — once the evidence is in — the measurement loop calls the winner. The point of wiring it this way is that nothing about the split leaks into the ad or the URL. The ad's destination is a single page's share link. A visitor who lands there is quietly assigned a variant and stays on it; the address bar never changes, so click attribution, canonical URLs, and retargeting all keep working. ## Setting one up You don't configure this by hand — you ask Erdo in a [conversation](/concepts#conversations): > "A/B test this landing page. Make a variant with a punchier hero, split traffic 50/50, and tell > me which gets more leads." The agent builds the variant pages, creates an [Experiment](/experiments) with those pages as its variants, gives each an **allocation** (the share of traffic it should get), and arms the measurement loop against the dataset your lead form writes to. Going live is the one step that waits for you: starting the experiment — the moment your page's traffic comes under its control — raises an approval card, as does swapping the variants of one that's already running. Approve it and the split is live. ## How traffic is split Each variant carries an **allocation percent** — the slice of visitors it should receive. The rules that turn those numbers into a real split are deliberately forgiving, so a half-configured experiment still serves sensibly: * Explicit weights are used as given and normalised by their total, so they need **not** sum to 100 — `[30, 30]` is a 50/50 split, not "30% each and 40% nowhere". * A variant left **without** a weight takes an equal share of whatever the explicit weights leave under 100. So control at 60 plus two un-weighted variants gives 60 / 20 / 20. * If nothing adds up (every weight zero or missing), traffic is split evenly rather than dropped. The control is whichever variant the ad's URL points at. Serving a different variant renders **that variant's content at the control's URL** — there is no redirect. ## Stickiness A visitor is assigned once and kept there. The first time someone hits the page, Erdo does a weighted pick and stores the result in a cookie scoped to that experiment; every later visit reads the cookie and serves the same variant. A variant that's since been removed from the experiment falls back to a fresh pick, so a stale cookie never shows a dead page. Assignment happens in the visitor's browser, which is what lets the page stay fast: the shared page shell is cached and served to everyone, and only the per-visitor choice runs live. The experiment never slows down the common, non-experiment page. ## Attribution: every lead and event carries its variant The whole point is being able to say "variant B produced more leads," so the assigned variant is stamped automatically onto the two things you measure with: * **Leads.** When the page's form submits, the variant the visitor was actually served is written onto the lead row — you don't have to build the page to copy it, and it can't be faked by the page's own markup. The lead lands in your [dataset](/data) with a `variant` column alongside the form fields, so per-variant conversion is a plain query. * **Analytics.** If you've enabled [page analytics](/pages), the assigned variant is attached to every event (pageviews, clicks, conversions) as a property, so your funnels split by variant with no extra tagging. Because attribution is stamped from the variant the visitor was *served* — not from anything the generated page declares about itself — it stays correct even if a page's own code is wrong about which variant it is. ## Deciding the winner Measurement is the same deterministic loop every [Experiment](/experiments) uses: a scheduled recipe reads your lead dataset, records one observation per variant per metric, and writes a decision-check breadcrumb. When you tell the agent which metric decides the test (say, lead-submit rate, higher is better) and how big a lead you'll accept as real, the loop does one more thing once the evidence gate clears and a clear winner has emerged: it **signals the decision**. That moves the experiment from `running` to `reading`, and Erdo writes up the outcome — the winning variant, the numbers, and the learning — and marks it `decided`. You can also just read the observations and make the call yourself; the automatic signal only fires when you've configured a decision metric and margin. Either way the experiment ends with a recorded decision you can point back to. ## What you see In your workspace the experiment lives under **Activity**, usually inside the [Workstream](/workstreams) that hosts the campaign. You watch the per-variant lead counts and conversion rates accrue, see the decision check flip to "gate passed," and read the final decision narrative when the loop (or you) calls it. # Ad accounts Source: https://docs.erdo.ai/ad-accounts List the advertising accounts your connected integrations can act on — the account id every other provider call needs, and which of your accounts are managers that hold no campaigns of their own. # Ad accounts Everything else about paid media is readable without this endpoint — you can already list an organization's campaigns, read their spend, and see the provider ids a [workstream](/workstreams) governs. What none of that gives you is the one field every one of those reads and every [paid-media lifecycle call](/paid-media) needs *first*: the provider's own account id. A Google Ads query, a status change, a budget change — every one of them takes an account id as its first argument, and until this endpoint nothing published one. You could see the campaigns; you could not run a single query against them. **`GET /v1/ad-accounts`** closes that gap. It asks each of your connected ad integrations to list the accounts it can reach, the same way the integration's own agent tools would, and returns them in one shape regardless of provider. ## What comes back ```json theme={null} { "ad_accounts": [ { "provider": "google_ads", "account_id": "8834039525", "name": "2200 Brickell", "currency_code": "USD", "time_zone": "America/New_York", "is_manager": false }, { "provider": "google_ads", "account_id": "5302012239", "name": "Erdo AI", "currency_code": "USD", "time_zone": "America/New_York", "is_manager": true } ] } ``` | Field | Meaning | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provider` | The integration the account belongs to — `google_ads` today. | | `account_id` | The provider's own identifier, unformatted (a Google Ads customer id as ten digits, no dashes). This is the value every provider read, and every paid-media lifecycle call, wants as `customer_id`. | | `name` | The account's own descriptive name — for showing a person which account is meant. Never used to identify one; address accounts by `account_id`. | | `currency_code` | The currency the account reports spend in. Accounts under the same organization are not required to share one, so read this before comparing spend across them. | | `time_zone` | The account's own reporting timezone — see below. | | `is_manager` | Whether the account is a manager (MCC) account. See below. | ## Manager accounts return empty, not an error `is_manager` is the field worth reading before you use an id, not after something goes wrong. A manager (MCC) account administers other accounts and holds no campaigns of its own, so a query aimed at one — spend, keywords, a status change — comes back **empty rather than failing**. That reads as "this account has no advertising," which is the most misleading answer available: it looks like a finding about the campaigns rather than a mistake about the address. It's also the ordinary shape here, not an edge case. Erdo provisions managed developments as sub-accounts under a manager — 2200 Brickell's campaigns live under the Erdo AI manager account shown above — so an organization's first account in this list is very often a manager whose id looks just as valid as the operating account underneath it. To make picking correctly the default, **operating accounts sort first**; a manager comes only after every operating account, and only if the organization has no operating accounts is a manager listed alone. A caller that just takes the first entry takes one it can actually query. (This is a different "manager" from Erdo's own [manager accounts](/manager-accounts), which is about one Erdo organization operating several client organizations. Same word, two unrelated hierarchies — a Google Ads MCC never implies anything about who administers your Erdo org, and vice versa.) ## Unavailable providers ```json theme={null} { "ad_accounts": [], "unavailable": [ { "provider": "google_ads", "reason": "Google Ads not connected: token refresh failed: invalid_grant" } ] } ``` `unavailable` names a connected provider whose accounts could not be listed, and why — an expired or revoked token, most often. This is deliberately kept apart from an organization simply having no ad accounts: a provider that has never been connected is not reported here at all, because "you have no Meta Ads accounts because you have no Meta Ads" is not a finding, it's the absence of one. But a provider you *did* connect going quiet must not just make `ad_accounts` shorter — a caller comparing this list against campaigns it can already see would otherwise conclude the account had been disconnected, when the real problem is a token that needs reconnecting. ## Timezone decides where "yesterday" falls `time_zone` is the account's own reporting timezone, and it matters the moment you compare this account against a daily figure. A provider's "yesterday" is yesterday in *that account's* timezone, not the caller's and not UTC — the same reason a [scheduled action](/scheduled-actions#the-schedule) lets you set an explicit `timezone` rather than defaulting silently. Read it here before joining anything to a day boundary. ## Reading it ```bash REST theme={null} curl https://api.erdo.ai/v1/ad-accounts \ -H "Authorization: Bearer YOUR_API_KEY" ``` In chat or over MCP, `erdo_list_ad_accounts` returns the same list, so an agent can pick an operating account for itself before running a provider query rather than guessing at an id. ## Feeding other calls This is a lookup step, not a destination — the `account_id` it returns is what you pass as `customer_id` everywhere else that asks for one. A [scheduled action](/scheduled-actions)'s steps run against a specific provider account: ```json theme={null} { "name": "performance", "app": "google_ads", "key": "query_google_ads", "input": { "customer_id": "8834039525", "query": "SELECT campaign.id, metrics.cost_micros FROM campaign WHERE campaign.id IN ({{scope.ids}}) AND segments.date DURING YESTERDAY" } } ``` A [paid-media lifecycle call](/paid-media) needs the same id to pause a campaign or change its budget: ```json theme={null} { "provider": "google_ads", "customer_id": "8834039525", "status": "paused" } ``` In both cases, `8834039525` is the `account_id` this endpoint returned for 2200 Brickell — not the `5302012239` beside it, which is the manager and holds no campaigns to query, pause, or re-budget. ## Related Pause, resume, or re-budget a campaign in the account this endpoint names. Declare reads and judgements that run against an account on a clock. Erdo's own manager/managed-organization hierarchy — a different "manager" from the one this page reports. The equivalent read for what's tagging your pages, rather than what you can advertise from. # Agents Source: https://docs.erdo.ai/agents Your AI workforce — specialized agents you recruit, brief, and configure to work on your business. An **agent** is a specialized AI worker. Instead of one general assistant, Erdo gives you a team you assemble from a gallery and brief in plain language. You tell an agent the outcome you want; it connects to your data, does the work, and reports back — asking before anything consequential. ## Recruit an agent Open **Agents** to browse the gallery, organized by area (marketing, sales, e-commerce, personal assistant, and more). Each agent describes what it's good at and which connections it needs. Find one that matches the job — a data analyst, a document analyzer, a security checker. Recruiting enables the agent for your organization. You can bind specific [datasets](/data) and [skills](/knowledge) so it starts with the right context. Start a [conversation](/concepts#conversations) and brief it. Results appear inline. ## Build a custom agent When no standard agent fits, create your own. A custom agent takes: * **Instructions** — what it should do, how to behave, what to prioritize. * **Reference documents** — files it should always have on hand. * **Pinned skills** — reusable [skills](/knowledge) it can call. Custom agents keep a **version history**, so you can change instructions freely and restore a previous version if a change doesn't work out. ## One agent, many deployments An agent is the durable worker: its name, instructions, knowledge, skills, and run history live in one place. A **deployment** is a way for people to reach that agent. The same agent can work in Erdo, talk on a phone call, or appear on a website through chat, voice, and video without becoming several disconnected copies. Open **Agents → Deployments** to see every website, voice-runtime, and phone deployment. Website deployments own only channel settings such as greeting, voice, appearance, allowed origins, usage limits, and meeting scheduling. Edit the agent to change its name or instructions; every attached deployment picks those changes up automatically. External website visitors can search only knowledge your organization has explicitly marked **Public** — an agent's private memories and internal skills are never exposed just because it is deployed. * Choose **Website** to attach an existing agent to an embeddable chat, voice, or video surface. See [Website Widget](/voice-widget). * Choose **Voice or phone** to open a guided chat that casts the voice and can assign a dedicated inbound number. See [Voice Calls](/voice). Deleting a deployment leaves the agent intact. Deleting the agent disables its attached deployments. ## Configure and teach Every agent has a **Knowledge** panel where it accumulates what it learns about your business — useful findings, known limitations, and optimizations — and reuses them on future runs. See [Knowledge](/knowledge) for how this works across your workspace. Some agents need a specific [connection](/data) to do their job — for example, an agent that books meetings needs Google Calendar connected. The agent will tell you what's missing if a connection is required. ## What agents can do Agents use **tools** to act: query a dataset, run code, search the web, build a [page](/pages), send an email, or place a [phone call](/voice). They choose the tools a job needs — you don't wire them up. Anything that changes your data or reaches outside Erdo goes through [review and approval](/concepts#review-and-approvals). # AddEvalCaseAPI mirrors erdo_add_eval_case. Source: https://docs.erdo.ai/api-reference/addevalcaseapi-mirrors-erdo_add_eval_case /api/openapi.json post /v1/evals/suites/{suiteSlug}/cases # AddManagedOrganizationMemberAPI adds a human member to a managed org by email (role member or Source: https://docs.erdo.ai/api-reference/addmanagedorganizationmemberapi-adds-a-human-member-to-a-managed-org-by-emailrole-member-or /api/openapi.json post /v1/managed-organizations/{orgSlug}/members admin, never owner), or leaves a pending invitation when no account exists yet. A managed org's only admin member is the manager's service principal, so this is the surface that seats people in it. Deliberately no MCP tool: granting a human durable membership in a client org stays off the model-facing tool surface for the same reason manager-key minting does (see the file header). # AddWorkstreamPhaseAPI mirrors erdo_add_workstream_phase. Source: https://docs.erdo.ai/api-reference/addworkstreamphaseapi-mirrors-erdo_add_workstream_phase /api/openapi.json post /v1/workstreams/{workstreamSlug}/phases # AdoptManagedOrganizationAPI brings an EXISTING org under the caller's manager org by redeeming a Source: https://docs.erdo.ai/api-reference/adoptmanagedorganizationapi-brings-an-existing-org-under-the-callers-managerorg-by-redeeming-a /api/openapi.json post /v1/managed-organization-adoptions one-time consent token. Two kinds redeem here: an ADOPTION token, minted by the target org's owner on the session surface (the manager credential alone can never adopt an arbitrary org — it must present a secret only the owner could have created), and a HANDOFF token, minted by the org's current manager for an ownerless managed org and pinned to the redeeming successor (the redeem then swaps the management edge atomically). Deliberately no MCP tool: durably re-homing an org under a manager stays off the model-facing tool surface for the same reason manager-key minting and member adds do (see the file header). The path is a sibling of /v1/managed-organizations because Encore rejects a static segment (\`adopt\`) alongside the parameterized \`/:orgSlug/members\` route. # AppendWorkstreamEventAPI mirrors erdo_append_workstream_event. Source: https://docs.erdo.ai/api-reference/appendworkstreameventapi-mirrors-erdo_append_workstream_event /api/openapi.json post /v1/workstreams/{workstreamSlug}/events # ArmWorkstreamLoopAPI mirrors erdo_arm_workstream_loop: schedule the recurring Source: https://docs.erdo.ai/api-reference/armworkstreamloopapi-mirrors-erdo_arm_workstream_loop:-schedule-the-recurring /api/openapi.json post /v1/workstreams/{workstreamSlug}/arm allocator reconciliation pass for a workstream. # AskDataQuestionAPI mirrors the erdo_ask_data_question MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/askdataquestionapi-mirrors-the-erdo_ask_data_question-mcp-tool-as-a-restendpoint /api/openapi.json post /v1/ask # AttachWorkstreamResourceAPI mirrors erdo_attach_workstream_resource. Source: https://docs.erdo.ai/api-reference/attachworkstreamresourceapi-mirrors-erdo_attach_workstream_resource /api/openapi.json post /v1/workstreams/{workstreamSlug}/resources # BacktestJudgeAPI mirrors erdo_backtest_judge: run a judge over the org's live Source: https://docs.erdo.ai/api-reference/backtestjudgeapi-mirrors-erdo_backtest_judge:-run-a-judge-over-the-orgs-live /api/openapi.json post /v1/judge-backtests experiments' variant pages of its kind and persist one prediction per variant. Synchronous and potentially slow. # CheckIntegrationConnectionAPI mirrors the erdo_check_integration_connection MCP tool. Source: https://docs.erdo.ai/api-reference/checkintegrationconnectionapi-mirrors-the-erdo_check_integration_connection-mcptool /api/openapi.json get /v1/integrations-connect/{app} # ConfigureIntegrationDatasetAPI updates an existing integration dataset's Source: https://docs.erdo.ai/api-reference/configureintegrationdatasetapi-updates-an-existing-integration-datasets /api/openapi.json post /v1/integration-datasets/{datasetID}/configure generic segment scope and optional canonical sync. # ConnectIntegrationAPI mirrors the erdo_connect_integration MCP tool. Source: https://docs.erdo.ai/api-reference/connectintegrationapi-mirrors-the-erdo_connect_integration-mcp-tool /api/openapi.json post /v1/integrations-connect # CreateConnectLinkAPI mirrors erdo_create_integration_connect_link. Source: https://docs.erdo.ai/api-reference/createconnectlinkapi-mirrors-erdo_create_integration_connect_link /api/openapi.json post /v1/integration-connect-links # CreateCustomDomainAPI registers a custom domain for the caller's org and Source: https://docs.erdo.ai/api-reference/createcustomdomainapi-registers-a-custom-domain-for-the-callers-org-and /api/openapi.json post /v1/custom-domains returns the DNS records the customer must create. # CreateDatasetAPI creates a new empty dataset. Source: https://docs.erdo.ai/api-reference/createdatasetapi-creates-a-new-empty-dataset /api/openapi.json post /v1/datasets-create # CreateDatasetFilterAPI mirrors the erdo_add_dataset_filter MCP tool. Source: https://docs.erdo.ai/api-reference/createdatasetfilterapi-mirrors-the-erdo_add_dataset_filter-mcp-tool /api/openapi.json post /v1/datasets/{datasetSlug}/filters # CreateEvalSuiteAPI mirrors erdo_create_eval_suite. Source: https://docs.erdo.ai/api-reference/createevalsuiteapi-mirrors-erdo_create_eval_suite /api/openapi.json post /v1/evals/suites # CreateEventPipelineAPI mirrors erdo_create_event_pipeline. Creation is where Source: https://docs.erdo.ai/api-reference/createeventpipelineapi-mirrors-erdo_create_event_pipeline-creation-is-where /api/openapi.json post /v1/event-pipelines the inbound source and auth are set; dataset.write targets are referenced by slug (a well-formed same-org slug that doesn't exist yet is auto-created and reported in created\_datasets), and the generated verification secret for the secret-bearing auth modes is returned exactly once, on this response. # CreateExperimentAPI mirrors erdo_create_experiment. Source: https://docs.erdo.ai/api-reference/createexperimentapi-mirrors-erdo_create_experiment /api/openapi.json post /v1/experiments # CreateHeartbeatAPI mirrors the erdo_create_heartbeat MCP tool. Source: https://docs.erdo.ai/api-reference/createheartbeatapi-mirrors-the-erdo_create_heartbeat-mcp-tool /api/openapi.json post /v1/heartbeats # CreateIntegrationDatasetAPI mirrors erdo_create_integration_dataset. Source: https://docs.erdo.ai/api-reference/createintegrationdatasetapi-mirrors-erdo_create_integration_dataset /api/openapi.json post /v1/integration-datasets # CreateKnowledgeAPI mirrors the erdo_create_knowledge MCP tool. Source: https://docs.erdo.ai/api-reference/createknowledgeapi-mirrors-the-erdo_create_knowledge-mcp-tool /api/openapi.json post /v1/knowledge # CreateKVStoreAPIv1 mirrors the erdo_create_kv_store MCP tool. Source: https://docs.erdo.ai/api-reference/createkvstoreapiv1-mirrors-the-erdo_create_kv_store-mcp-tool /api/openapi.json post /v1/kv # CreateManagedOrganizationAPI mirrors erdo_create_managed_organization. Source: https://docs.erdo.ai/api-reference/createmanagedorganizationapi-mirrors-erdo_create_managed_organization /api/openapi.json post /v1/managed-organizations # CreateManagedOrganizationHandoffTokenAPI mints a one-time token that offers an OWNERLESS managed Source: https://docs.erdo.ai/api-reference/createmanagedorganizationhandofftokenapi-mints-a-one-time-token-that-offers-anownerless-managed /api/openapi.json post /v1/managed-organizations/{orgSlug}/handoff-tokens org to a named successor manager, redeemable at POST /v1/managed-organization-adoptions. The owner-minted adoption flow can never run for such an org (it has no owner to consent), so without this it would be permanently pinned to whichever org created it. Consent stays sound: for an ownerless managed org the current manager is the org's entire existing authority, so the handoff grants the successor nothing the minter did not already hold — the minter is RELINQUISHING access. The org service refuses the mint the moment the org has a real owner. Deliberately no MCP tool: durably re-homing an org stays off the model-facing tool surface, like manager-key minting and member adds (see the file header). # CreateManagerKeyAPI creates or rotates the manager credential. The raw key is returned exactly once. Source: https://docs.erdo.ai/api-reference/createmanagerkeyapi-creates-or-rotates-the-manager-credential-the-raw-key-isreturned-exactly-once /api/openapi.json post /v1/manager-key # CreateOrganizationAPI creates a new top-level organization owned by the Source: https://docs.erdo.ai/api-reference/createorganizationapi-creates-a-new-top-level-organization-owned-by-the /api/openapi.json post /v1/organizations caller, for \`erdo org create\`. Until now org creation lived only on the internal session surface (POST /organization), which the external-surface gate keeps off-limits to API keys — so a CLI/API user had no way to create an org at all. This wrapper exposes the exact same self-serve capability the web UI already grants every authenticated non-guest user, so it widens nothing: the caller becomes the owner, guests are denied (in-handler like the managed-org mutations, plus the deny-guest tag at the edge), and scoped API keys are denied both here and by the scoped-key middleware's hard denylist. Name/slug validation errors pass through from the organization service. # CreatePageReviewAPI mirrors erdo_review_pages: start a review of one or more Source: https://docs.erdo.ai/api-reference/createpagereviewapi-mirrors-erdo_review_pages:-start-a-review-of-one-or-more /api/openapi.json post /v1/page-reviews external page URLs with the real landing critic. Returns a review id immediately (status=pending); poll GET /v1/page-reviews/:reviewID for the typed findings. # CreateProjectAPI mirrors erdo_create_project. Source: https://docs.erdo.ai/api-reference/createprojectapi-mirrors-erdo_create_project /api/openapi.json post /v1/projects # CreateRealtimeTicketAPI creates a browser-origin-bound ticket for the Source: https://docs.erdo.ai/api-reference/createrealtimeticketapi-creates-a-browser-origin-bound-ticket-for-the /api/openapi.json post /v1/realtime/tickets authenticated request's active organization. The raw WebSocket upgrade stays at /websocket/connect, while this deliberate /v1 bootstrap keeps API keys on the documented public surface and out of browser JavaScript. Realtime transport is REST-specific rather than an MCP/CLI domain capability: MCP and CLI clients do not keep a browser WebSocket open. # CreateSendingDomainAPI registers a sending domain for the caller's org and Source: https://docs.erdo.ai/api-reference/createsendingdomainapi-registers-a-sending-domain-for-the-callers-org-and /api/openapi.json post /v1/sending-domains returns the DKIM/SPF (and, for receiving, MX) records the customer must create at their own DNS provider. The domain comes back pending\_dns and nothing is sent from it until those records exist and verify — sends keep going out from Erdo's address in the meantime. # CreateThreadAPI mirrors the erdo_create_thread MCP tool. Source: https://docs.erdo.ai/api-reference/createthreadapi-mirrors-the-erdo_create_thread-mcp-tool /api/openapi.json post /v1/threads-create # CreateTokenAPI mints a new API token for the caller. The raw token is returned Source: https://docs.erdo.ai/api-reference/createtokenapi-mints-a-new-api-token-for-the-caller-the-raw-token-is-returned /api/openapi.json post /v1/tokens exactly once. # CreateWorkstreamAPI mirrors erdo_create_workstream. Source: https://docs.erdo.ai/api-reference/createworkstreamapi-mirrors-erdo_create_workstream /api/openapi.json post /v1/workstreams # DecideApprovalAPI mirrors erdo_decide_approval. Source: https://docs.erdo.ai/api-reference/decideapprovalapi-mirrors-erdo_decide_approval /api/openapi.json post /v1/approvals/{id}/decide # DecideReviewItemAPI mirrors erdo_decide_review_item. Source: https://docs.erdo.ai/api-reference/decidereviewitemapi-mirrors-erdo_decide_review_item /api/openapi.json post /v1/review-items/{id}/decide # DecisionScorecardAPI mirrors erdo_decision_scorecard: raw aggregates with their Source: https://docs.erdo.ai/api-reference/decisionscorecardapi-mirrors-erdo_decision_scorecard:-raw-aggregates-with-their /api/openapi.json get /v1/decisions-scorecard denominators, stratified by decision class and evidence kind, with no eligibility verdict and no pooled outcome rate. # Declares what happens when new rows land in a dataset: invoke an action on Source: https://docs.erdo.ai/api-reference/declares-what-happens-when-new-rows-land-in-a-dataset:-invoke-an-action-on /api/openapi.json put /v1/dataset-row-actions each new row, and optionally record the result in another dataset. The declaration replaces whatever was previously declared under the same name; enabled false removes it. # Declares work to run on a schedule: an ordered list of named steps, each an Source: https://docs.erdo.ai/api-reference/declares-work-to-run-on-a-schedule:-an-ordered-list-of-named-steps-each-an /api/openapi.json put /v1/scheduled-actions action to invoke or a script to run, bounded by the provider objects a workstream governs. The declaration replaces whatever was previously declared under the same name; enabled false removes it. REST only, with no MCP write tool, and the asymmetry is the point. The scope a run acts on is read from the workstream at run time, so an automation that could declare its own scheduled work could hand itself a wider one than it was given — which is exactly what moving scope out of prose was for. # DeleteCustomDomainAPI removes a custom domain registration (and its CDN Source: https://docs.erdo.ai/api-reference/deletecustomdomainapi-removes-a-custom-domain-registration-and-its-cdn /api/openapi.json delete /v1/custom-domains/{domain} hostname), named by the domain itself. # DeleteDatasetAPI deletes a dataset by slug. Source: https://docs.erdo.ai/api-reference/deletedatasetapi-deletes-a-dataset-by-slug /api/openapi.json delete /v1/datasets/{datasetSlug} # DeleteDatasetFilterAPI mirrors the erdo_remove_dataset_filter MCP tool. Source: https://docs.erdo.ai/api-reference/deletedatasetfilterapi-mirrors-the-erdo_remove_dataset_filter-mcp-tool /api/openapi.json delete /v1/datasets/{datasetSlug}/filters/{filterID} # DeleteDatasetRowsAPI deletes rows from a dataset. Source: https://docs.erdo.ai/api-reference/deletedatasetrowsapi-deletes-rows-from-a-dataset /api/openapi.json delete /v1/datasets/{datasetSlug}/rows # DeleteIntegrationAPI removes a connected integration: the credential row is Source: https://docs.erdo.ai/api-reference/deleteintegrationapi-removes-a-connected-integration:-the-credential-row-is /api/openapi.json delete /v1/integrations/{integrationID} deleted and the integration's datasets are cascade-cleaned by the same event the web app's delete publishes (erdo #1665). # DeleteKnowledgeAPI mirrors the erdo_delete_knowledge MCP tool. Source: https://docs.erdo.ai/api-reference/deleteknowledgeapi-mirrors-the-erdo_delete_knowledge-mcp-tool /api/openapi.json delete /v1/knowledge/{knowledgeObjectID} # DeleteKVItemAPIv1 mirrors the erdo_delete_kv_item MCP tool. Source: https://docs.erdo.ai/api-reference/deletekvitemapiv1-mirrors-the-erdo_delete_kv_item-mcp-tool /api/openapi.json delete /v1/kv/{slug}/items/{key} # DeletePageAPI mirrors the erdo_delete_page MCP tool. Source: https://docs.erdo.ai/api-reference/deletepageapi-mirrors-the-erdo_delete_page-mcp-tool /api/openapi.json delete /v1/pages/{pageID} # DeleteSendingDomainAPI removes a sending-domain registration, named by the Source: https://docs.erdo.ai/api-reference/deletesendingdomainapi-removes-a-sending-domain-registration-named-by-the /api/openapi.json delete /v1/sending-domains/{domain} domain itself. Outbound agent email reverts to Erdo's own address. # DeployPageAPI mirrors the erdo_deploy_page MCP tool. Source: https://docs.erdo.ai/api-reference/deploypageapi-mirrors-the-erdo_deploy_page-mcp-tool /api/openapi.json post /v1/pages # DiagnoseExperimentAPI mirrors erdo_diagnose_experiment_personas: run Source: https://docs.erdo.ai/api-reference/diagnoseexperimentapi-mirrors-erdo_diagnose_experiment_personas:-run /api/openapi.json post /v1/experiments/{experimentSlug}/diagnose Stage-B diagnostic persona sessions on an experiment's surviving variants. Narratives land as action\_taken observations, readable via GET /v1/experiments/:slug/observations. # DiscoverIntegrationTablesAPI mirrors erdo_discover_integration_tables. Source: https://docs.erdo.ai/api-reference/discoverintegrationtablesapi-mirrors-erdo_discover_integration_tables /api/openapi.json get /v1/integrations/{integrationRef}/tables The path param is the app key or integration id; schema\_name comes from the query string. # FetchDatasetContentsAPI mirrors the erdo_fetch_dataset_contents MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/fetchdatasetcontentsapi-mirrors-the-erdo_fetch_dataset_contents-mcp-tool-as-arest-endpoint /api/openapi.json post /v1/datasets/{datasetSlug}/fetch # GatherDatasetContextAPI mirrors the erdo_gather_dataset_context MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/gatherdatasetcontextapi-mirrors-the-erdo_gather_dataset_context-mcp-tool-as-arest-endpoint /api/openapi.json get /v1/dataset-context # GetAgentRunAPIv1 mirrors erdo_get_agent_run. Source: https://docs.erdo.ai/api-reference/getagentrunapiv1-mirrors-erdo_get_agent_run /api/openapi.json get /v1/runs/{runID} # GetApprovalAPI mirrors erdo_get_approval: one request in full, the read a Source: https://docs.erdo.ai/api-reference/getapprovalapi-mirrors-erdo_get_approval:-one-request-in-full-the-read-a /api/openapi.json get /v1/approvals/{id} decision is made against — the itemized projection, the recorded reason, and the scope options — where the list is sized for scanning. # GetArtifactAPI mirrors the erdo_get_artifact MCP tool. Source: https://docs.erdo.ai/api-reference/getartifactapi-mirrors-the-erdo_get_artifact-mcp-tool /api/openapi.json get /v1/artifacts/{artifactID} # GetAutonomyGatesAPI mirrors erdo_get_autonomy_gates: read which risk classes Source: https://docs.erdo.ai/api-reference/getautonomygatesapi-mirrors-erdo_get_autonomy_gates:-read-which-risk-classes /api/openapi.json get /v1/autonomy-gates of agent action still raise an approval card when the org's autonomy is propose. gates == null is the legacy behavior — every gated action raises a card. # GetAutonomyModeAPI mirrors erdo_get_autonomy_mode: read the org's engine Source: https://docs.erdo.ai/api-reference/getautonomymodeapi-mirrors-erdo_get_autonomy_mode:-read-the-orgs-engine /api/openapi.json get /v1/autonomy-mode autonomy mode (autopilot | propose | strict). # GetConnectionScopeAPI mirrors the erdo_get_connection_scope MCP tool. Source: https://docs.erdo.ai/api-reference/getconnectionscopeapi-mirrors-the-erdo_get_connection_scope-mcp-tool /api/openapi.json get /v1/integrations/{integrationID}/connection-scope # GetDatasetNotificationAPI reports who is emailed when new rows land in a Source: https://docs.erdo.ai/api-reference/getdatasetnotificationapi-reports-who-is-emailed-when-new-rows-land-in-a /api/openapi.json get /v1/dataset-notifications dataset, read back off the automation itself rather than a second copy of the setting. # GetDatasetSchemaAPI mirrors the erdo_get_dataset_schema MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/getdatasetschemaapi-mirrors-the-erdo_get_dataset_schema-mcp-tool-as-a-restendpoint /api/openapi.json get /v1/datasets/{datasetID}/schema # GetDecisionAPI mirrors erdo_get_decision: one decision in full — the Source: https://docs.erdo.ai/api-reference/getdecisionapi-mirrors-erdo_get_decision:-one-decision-in-full-—-the /api/openapi.json get /v1/decisions/{decisionSlug} commitment, its exact actions and how each ended, its declared effects and the evidence that settled them, and supersession in both directions. A slug belonging to another organization reads as NotFound, so it cannot be probed for existence. # GetEmailAPI mirrors erdo_get_email, returning one message with both bodies as Source: https://docs.erdo.ai/api-reference/getemailapi-mirrors-erdo_get_email-returning-one-message-with-both-bodies-as /api/openapi.json get /v1/emails/{emailID} they were sent. # GetEvalRunAPI mirrors erdo_get_eval_run. Source: https://docs.erdo.ai/api-reference/getevalrunapi-mirrors-erdo_get_eval_run /api/openapi.json get /v1/evals/runs/{runID} # GetEvalSuiteAPI mirrors erdo_get_eval_suite. Source: https://docs.erdo.ai/api-reference/getevalsuiteapi-mirrors-erdo_get_eval_suite /api/openapi.json get /v1/evals/suites/{suiteSlug} # GetEventPipelineAPI mirrors erdo_get_event_pipeline (slug or uuid). Source: https://docs.erdo.ai/api-reference/geteventpipelineapi-mirrors-erdo_get_event_pipeline-slug-or-uuid /api/openapi.json get /v1/event-pipelines/{pipelineSlug} # GetExperimentAPI mirrors erdo_get_experiment (slug or uuid). Source: https://docs.erdo.ai/api-reference/getexperimentapi-mirrors-erdo_get_experiment-slug-or-uuid /api/openapi.json get /v1/experiments/{experimentSlug} # GetExperimentPolicyAPI mirrors erdo_get_experiment_policy — the decision Source: https://docs.erdo.ai/api-reference/getexperimentpolicyapi-mirrors-erdo_get_experiment_policy-—-the-decision /api/openapi.json get /v1/experiments/{experimentSlug}/policy policy's latest state (posteriors, allocation, stop probabilities). # GetKVItemAPIv1 mirrors the erdo_get_kv_item MCP tool. Source: https://docs.erdo.ai/api-reference/getkvitemapiv1-mirrors-the-erdo_get_kv_item-mcp-tool /api/openapi.json get /v1/kv/{slug}/items/{key} # GetPageReviewAPI mirrors erdo_get_page_review: read a review's status and, Source: https://docs.erdo.ai/api-reference/getpagereviewapi-mirrors-erdo_get_page_review:-read-a-reviews-status-and /api/openapi.json get /v1/page-reviews/{reviewID} once ready, its typed per-page findings (strengths + defects), the deterministic conversion score with its category breakdown, and real Lighthouse scores when available. # GetPageTrackingAPI mirrors the erdo_get_page_tracking MCP tool: read which Source: https://docs.erdo.ai/api-reference/getpagetrackingapi-mirrors-the-erdo_get_page_tracking-mcp-tool:-read-which /api/openapi.json get /v1/page-tracking analytics destinations the caller org's published pages send visitor data to. The companion read to /v1/page-analytics/query — that endpoint reports what visitors did, this one reports whether anything was tagging them, which is the difference between "376 engaged sessions, retarget them" and discovering no pixel was ever firing. Returns only public, client-side identifiers (the \`phc\_…\`/\`G-…\`/pixel-id values already embedded in published page JS); the provider's internal numeric project id is stripped here. Read-only by design: destinations are Erdo-provisioned into the account that must be able to use them, never caller-chosen. These are the vendor destinations stored on the organization. Erdo's own first-party page-events beacon is provisioned per page at render time and is not among them, so an empty list means no vendor tracking rather than no recording. # GetReviewItemAPI mirrors erdo_get_review_item. Source: https://docs.erdo.ai/api-reference/getreviewitemapi-mirrors-erdo_get_review_item /api/openapi.json get /v1/review-items/{id} # GetSendingDomainAPI reads one sending domain, named by the domain itself. Source: https://docs.erdo.ai/api-reference/getsendingdomainapi-reads-one-sending-domain-named-by-the-domain-itself /api/openapi.json get /v1/sending-domains/{domain} Mirrors erdo\_get\_sending\_domain — the endpoint to poll while a customer's DNS change propagates. # GetThreadMessagesAPI mirrors the erdo_get_thread_messages MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/getthreadmessagesapi-mirrors-the-erdo_get_thread_messages-mcp-tool-as-a-restendpoint /api/openapi.json get /v1/threads/{threadID}/messages # GetVoiceWidgetConversationAPI mirrors erdo_voice_widget_conversation_get, Source: https://docs.erdo.ai/api-reference/getvoicewidgetconversationapi-mirrors-erdo_voice_widget_conversation_get /api/openapi.json post /v1/voice/widget-conversations/get returning the transcript plus the structured per-turn LLM metrics (\`turns\`). # GetWorkstreamAPI mirrors erdo_get_workstream (slug or uuid). Source: https://docs.erdo.ai/api-reference/getworkstreamapi-mirrors-erdo_get_workstream-slug-or-uuid /api/openapi.json get /v1/workstreams/{workstreamSlug} # GroundPersonasAPI mirrors erdo_ground_personas: ground the org's persona Source: https://docs.erdo.ai/api-reference/groundpersonasapi-mirrors-erdo_ground_personas:-ground-the-orgs-persona /api/openapi.json post /v1/persona-grounding skills in measured page-events sessions. Returns what the pass did (or the gated no-op reason). # IOSScreenshotAPI mirrors the erdo_ios_screenshot MCP tool: capture a page on a Source: https://docs.erdo.ai/api-reference/iosscreenshotapi-mirrors-the-erdo_ios_screenshot-mcp-tool:-capture-a-page-on-a /api/openapi.json post /v1/screenshot/ios REAL iPhone (real iOS Safari) — for iOS-Safari-only behaviour headless Chromium can't see. Returns a job\_id; poll /v1/screenshot/result with it for the PNG. # JudgeCalibrationAPI mirrors erdo_judge_calibration. Source: https://docs.erdo.ai/api-reference/judgecalibrationapi-mirrors-erdo_judge_calibration /api/openapi.json get /v1/judge-calibration # ListActivityFeedAPI mirrors erdo_list_activity_feed: the unified, ranked, Source: https://docs.erdo.ai/api-reference/listactivityfeedapi-mirrors-erdo_list_activity_feed:-the-unified-ranked /api/openapi.json get /v1/activity/feed read-only feed (attention items, approvals, workstream events, catalog updates, urgent cause-clusters). Query params carry limit/offset/categories/ scope. Responding to an item routes through /v1/attention/:id/respond and /v1/approvals/:id/decide — this endpoint is a view only. Omit categories for the attention-first default; catalog and execution history are opt-in. # ListActivityHistoryAPI mirrors erdo_list_activity_history: the org's durable, Source: https://docs.erdo.ai/api-reference/listactivityhistoryapi-mirrors-erdo_list_activity_history:-the-orgs-durable /api/openapi.json get /v1/activity/history chronological history ledger of consequential mutations (approvals, publishes, permission grants, flag changes, membership changes) with actor and authorization provenance. Query params carry the time window, verbs, resource, and actor filters. Read-only. Powers \`erdo history\` and the /activity History lens; org-scoped to the caller's active organization. # ListAgentRunsAPI mirrors erdo_list_agent_runs. Source: https://docs.erdo.ai/api-reference/listagentrunsapi-mirrors-erdo_list_agent_runs /api/openapi.json get /v1/runs # ListApprovalsAPI mirrors erdo_list_approvals. Source: https://docs.erdo.ai/api-reference/listapprovalsapi-mirrors-erdo_list_approvals /api/openapi.json get /v1/approvals # ListArtifactsAPI mirrors the erdo_list_artifacts MCP tool. Source: https://docs.erdo.ai/api-reference/listartifactsapi-mirrors-the-erdo_list_artifacts-mcp-tool /api/openapi.json get /v1/artifacts # ListAttentionItemsAPI mirrors erdo_list_attention_items. Source: https://docs.erdo.ai/api-reference/listattentionitemsapi-mirrors-erdo_list_attention_items /api/openapi.json get /v1/attention # ListConnectLinksAPI mirrors erdo_list_integration_connect_links. Source: https://docs.erdo.ai/api-reference/listconnectlinksapi-mirrors-erdo_list_integration_connect_links /api/openapi.json get /v1/integration-connect-links # ListCrossCustomerPriorsAPI mirrors erdo_list_cross_customer_priors. Source: https://docs.erdo.ai/api-reference/listcrosscustomerpriorsapi-mirrors-erdo_list_cross_customer_priors /api/openapi.json get /v1/cross-customer-priors # ListCustomDomainsAPI lists the caller org's custom page domains with live Source: https://docs.erdo.ai/api-reference/listcustomdomainsapi-lists-the-caller-orgs-custom-page-domains-with-live /api/openapi.json get /v1/custom-domains lifecycle status. Mirrors erdo\_list\_custom\_domains. # ListDatasetFiltersAPI mirrors the erdo_list_dataset_filters MCP tool. Source: https://docs.erdo.ai/api-reference/listdatasetfiltersapi-mirrors-the-erdo_list_dataset_filters-mcp-tool /api/openapi.json get /v1/datasets/{datasetSlug}/filters # ListDatasetRevisionsAPI mirrors the erdo_list_dataset_revisions MCP tool. Source: https://docs.erdo.ai/api-reference/listdatasetrevisionsapi-mirrors-the-erdo_list_dataset_revisions-mcp-tool /api/openapi.json get /v1/datasets/{datasetSlug}/revisions # ListDatasetsAPI mirrors the erdo_list_datasets MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/listdatasetsapi-mirrors-the-erdo_list_datasets-mcp-tool-as-a-rest-endpoint /api/openapi.json get /v1/datasets # ListDecisionsAPI mirrors erdo_list_decisions. Source: https://docs.erdo.ai/api-reference/listdecisionsapi-mirrors-erdo_list_decisions /api/openapi.json get /v1/decisions # ListEmailsAPI mirrors erdo_list_emails. Filters ride the query string, except Source: https://docs.erdo.ai/api-reference/listemailsapi-mirrors-erdo_list_emails-filters-ride-the-query-string-except /api/openapi.json get /v1/emails the structured \`context\` probe, which needs a JSON body — see the POST form. # ListEmailsPostAPI is the POST body form of the list endpoint. It exists for Source: https://docs.erdo.ai/api-reference/listemailspostapi-is-the-post-body-form-of-the-list-endpoint-it-exists-for /api/openapi.json post /v1/emails-query the \`context\` filter specifically: "every email about this lead" is a nested JSON object, which does not survive a query string cleanly. # ListEvalRunsAPI mirrors erdo_list_eval_runs. Source: https://docs.erdo.ai/api-reference/listevalrunsapi-mirrors-erdo_list_eval_runs /api/openapi.json get /v1/evals/runs # ListEvalSuitesAPI mirrors erdo_list_eval_suites. Source: https://docs.erdo.ai/api-reference/listevalsuitesapi-mirrors-erdo_list_eval_suites /api/openapi.json get /v1/evals/suites # ListEventPipelineExecutionsAPI mirrors erdo_list_event_pipeline_executions. Source: https://docs.erdo.ai/api-reference/listeventpipelineexecutionsapi-mirrors-erdo_list_event_pipeline_executions /api/openapi.json get /v1/event-pipelines/{pipelineSlug}/executions # ListEventPipelinesAPI mirrors erdo_list_event_pipelines. Source: https://docs.erdo.ai/api-reference/listeventpipelinesapi-mirrors-erdo_list_event_pipelines /api/openapi.json get /v1/event-pipelines # ListExperimentObservationsAPI mirrors erdo_list_experiment_observations. Source: https://docs.erdo.ai/api-reference/listexperimentobservationsapi-mirrors-erdo_list_experiment_observations /api/openapi.json get /v1/experiments/{experimentSlug}/observations # ListExperimentsAPI mirrors erdo_list_experiments. Source: https://docs.erdo.ai/api-reference/listexperimentsapi-mirrors-erdo_list_experiments /api/openapi.json get /v1/experiments # ListHeartbeatExecutionsAPI mirrors the erdo_list_heartbeat_executions MCP tool. Source: https://docs.erdo.ai/api-reference/listheartbeatexecutionsapi-mirrors-the-erdo_list_heartbeat_executions-mcp-tool /api/openapi.json get /v1/heartbeats/{heartbeatID}/executions # ListHeartbeatsAPI mirrors the erdo_list_heartbeats MCP tool. Source: https://docs.erdo.ai/api-reference/listheartbeatsapi-mirrors-the-erdo_list_heartbeats-mcp-tool /api/openapi.json get /v1/heartbeats # ListIntegrationsAPI mirrors the erdo_list_integrations MCP tool. Source: https://docs.erdo.ai/api-reference/listintegrationsapi-mirrors-the-erdo_list_integrations-mcp-tool /api/openapi.json get /v1/integrations # ListKnowledgeAPI mirrors the erdo_list_knowledge MCP tool. Source: https://docs.erdo.ai/api-reference/listknowledgeapi-mirrors-the-erdo_list_knowledge-mcp-tool /api/openapi.json get /v1/knowledge # ListKVStoresAPIv1 mirrors the erdo_list_kv_stores MCP tool. Source: https://docs.erdo.ai/api-reference/listkvstoresapiv1-mirrors-the-erdo_list_kv_stores-mcp-tool /api/openapi.json get /v1/kv # ListManagedOrganizationsAPI mirrors erdo_list_managed_organizations. Source: https://docs.erdo.ai/api-reference/listmanagedorganizationsapi-mirrors-erdo_list_managed_organizations /api/openapi.json get /v1/managed-organizations # ListOrganizationsAPI lists the orgs the caller belongs to, for `erdo org list` Source: https://docs.erdo.ai/api-reference/listorganizationsapi-lists-the-orgs-the-caller-belongs-to-for-`erdo-org-list` /api/openapi.json get /v1/organizations / \`erdo org use\`. # ListPageReviewsAPI mirrors erdo_list_page_reviews: past reviews newest first Source: https://docs.erdo.ai/api-reference/listpagereviewsapi-mirrors-erdo_list_page_reviews:-past-reviews-newest-first /api/openapi.json get /v1/page-reviews as lean summaries (id, status, URLs, goal, scores) — filter by url to track one page over time. The full findings stay on GET /v1/page-reviews/:reviewID. # ListPagesAPI mirrors the erdo_list_pages MCP tool. Source: https://docs.erdo.ai/api-reference/listpagesapi-mirrors-the-erdo_list_pages-mcp-tool /api/openapi.json get /v1/pages # ListProjectsAPI mirrors erdo_list_projects. Source: https://docs.erdo.ai/api-reference/listprojectsapi-mirrors-erdo_list_projects /api/openapi.json get /v1/projects # ListReceivedEmailsAPI mirrors erdo_list_received_emails: the messages people have sent back to Source: https://docs.erdo.ai/api-reference/listreceivedemailsapi-mirrors-erdo_list_received_emails:-the-messages-peoplehave-sent-back-to /api/openapi.json get /v1/received-emails the org's own sending address, newest first. \`domain\` may be omitted — an organization has one sending domain, so the read resolves it — and \`limit\` caps the page. Unlike the rest of this group it needs only org membership: a reply is business correspondence to read, not a registration to change. # ListReviewItemsAPI mirrors erdo_list_review_items. Source: https://docs.erdo.ai/api-reference/listreviewitemsapi-mirrors-erdo_list_review_items /api/openapi.json get /v1/review-items # Lists the advertising accounts this organization's connected integrations can Source: https://docs.erdo.ai/api-reference/lists-the-advertising-accounts-this-organizations-connected-integrations-can /api/openapi.json get /v1/ad-accounts act on: provider, account id, name, currency, reporting timezone, and whether the account is a manager (MCC). Every provider read takes an account id first, and until this nothing published named one — so a consumer could read an organization's campaigns and their spend and still not be able to run a single provider query. Address reads to an operating account: a query aimed at a manager returns nothing rather than failing, which reads as an account with no advertising. # Lists what already happens to new rows in a dataset: every declaration on it, Source: https://docs.erdo.ai/api-reference/lists-what-already-happens-to-new-rows-in-a-dataset:-every-declaration-on-it /api/openapi.json get /v1/dataset-row-actions the actions each invokes, and where results are written. Read back off the automations themselves rather than a second copy of the declarations. Pass name to read a single one. # Lists what an organization has declared to run on a schedule: each Source: https://docs.erdo.ai/api-reference/lists-what-an-organization-has-declared-to-run-on-a-schedule:-each /api/openapi.json get /v1/scheduled-actions declaration's steps, its cron schedule, and the workstream whose external refs bound what it may act on. Read back off the automations themselves rather than a second copy of the declarations. Pass name to read a single one. # ListSendingDomainsAPI lists the domains the caller org's outbound agent email Source: https://docs.erdo.ai/api-reference/listsendingdomainsapi-lists-the-domains-the-caller-orgs-outbound-agent-email /api/openapi.json get /v1/sending-domains can be sent from, with each domain's verification status and the DNS records that must exist for it. Mirrors erdo\_list\_sending\_domains. # ListThreadsAPI mirrors the erdo_list_threads MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/listthreadsapi-mirrors-the-erdo_list_threads-mcp-tool-as-a-rest-endpoint /api/openapi.json get /v1/threads # ListTokensAPI lists the caller's API tokens. It never returns the token hash or Source: https://docs.erdo.ai/api-reference/listtokensapi-lists-the-callers-api-tokens-it-never-returns-the-token-hash-or /api/openapi.json get /v1/tokens the raw secret — only the display hint and metadata. # ListVoiceWidgetConversationsAPI mirrors erdo_voice_widget_conversation_list. Source: https://docs.erdo.ai/api-reference/listvoicewidgetconversationsapi-mirrors-erdo_voice_widget_conversation_list /api/openapi.json get /v1/voice/widget-conversations The widget handle + paging ride the query string. # ListVoiceWidgetConversationsPostAPI is the POST body form of the list endpoint, Source: https://docs.erdo.ai/api-reference/listvoicewidgetconversationspostapi-is-the-post-body-form-of-the-list-endpoint /api/openapi.json post /v1/voice/widget-conversations for callers that prefer a JSON body over query params. # ListWorkstreamEventsAPI mirrors erdo_list_workstream_events. Source: https://docs.erdo.ai/api-reference/listworkstreameventsapi-mirrors-erdo_list_workstream_events /api/openapi.json get /v1/workstreams/{workstreamSlug}/events # ListWorkstreamsAPI mirrors erdo_list_workstreams. Source: https://docs.erdo.ai/api-reference/listworkstreamsapi-mirrors-erdo_list_workstreams /api/openapi.json get /v1/workstreams # MeAPI returns the authenticated user and their currently-active org (the one Source: https://docs.erdo.ai/api-reference/meapi-returns-the-authenticated-user-and-their-currently-active-org-the-one /api/openapi.json get /v1/me resolved from the token + X-Organization-ID header). # ProvisionManagedOrganizationAdsContainerAPI gives a managed org its own Google Ads container in Source: https://docs.erdo.ai/api-reference/provisionmanagedorganizationadscontainerapi-gives-a-managed-org-its-own-googleads-container-in /api/openapi.json post /v1/managed-organizations/{orgSlug}/ads-container one call: a fresh sub-account under the MANAGER org's Google Ads manager account (MCC), plus a delegated google\_ads connection in the managed org that operates it. Idempotent — an org that already has a delegated connection under this MCC gets it back (already\_provisioned: true), never a second sub-account. Deliberately no MCP tool: creating a billable provider account and a durable delegated credential stays off the model-facing tool surface, like manager-key minting and member adds (see the file header). # QueryDataNaturalLanguageAPI mirrors the erdo_query_data MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/querydatanaturallanguageapi-mirrors-the-erdo_query_data-mcp-tool-as-a-restendpoint /api/openapi.json post /v1/datasets/{datasetSlug}/query-nl # QueryPageAnalyticsAPI mirrors the erdo_page_analytics_query MCP tool: run a Source: https://docs.erdo.ai/api-reference/querypageanalyticsapi-mirrors-the-erdo_page_analytics_query-mcp-tool:-run-a /api/openapi.json post /v1/page-analytics/query read-only HogQL query against the caller org's page-analytics events. Thin — resolves the caller's org from ctx and hands the query to the organization service (which re-checks member RBAC and pins the query to the org's own project). A rejected HogQL propagates as InvalidArgument with PostHog's own message, deliberately, so the caller can fix and retry. # ReadWorkstreamLedgerAPI mirrors erdo_read_workstream_ledger: the allocator's Source: https://docs.erdo.ai/api-reference/readworkstreamledgerapi-mirrors-erdo_read_workstream_ledger:-the-allocators /api/openapi.json get /v1/workstreams/{workstreamSlug}/ledger single ledger read (budget, experiments + observations, calibration, steering notes, attention items, allocator recommendation). # RecordAllocationDecisionAPI mirrors erdo_record_allocation_decision: record Source: https://docs.erdo.ai/api-reference/recordallocationdecisionapi-mirrors-erdo_record_allocation_decision:-record /api/openapi.json post /v1/experiments/{experimentSlug}/allocation-decisions that the caller is acting against the allocator's recommendation for one bet. Experiment-scoped (not workstream-scoped) because the overridden bet is a variant of one experiment — the same identity the ledger's allocator\_recommendation names. # RemoveEvalCaseAPI mirrors erdo_remove_eval_case. Source: https://docs.erdo.ai/api-reference/removeevalcaseapi-mirrors-erdo_remove_eval_case /api/openapi.json delete /v1/evals/suites/{suiteSlug}/cases/{caseName} # RenderChartAPI mirrors the erdo_render_chart MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/renderchartapi-mirrors-the-erdo_render_chart-mcp-tool-as-a-rest-endpoint /api/openapi.json post /v1/render/chart # RenderTableAPI mirrors the erdo_render_table MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/rendertableapi-mirrors-the-erdo_render_table-mcp-tool-as-a-rest-endpoint /api/openapi.json post /v1/render/table # RespondAttentionItemAPI mirrors erdo_respond_attention_item: answer a choice Source: https://docs.erdo.ai/api-reference/respondattentionitemapi-mirrors-erdo_respond_attention_item:-answer-a-choice /api/openapi.json post /v1/attention/{itemID}/respond or escalation, acknowledge (mark read), or dismiss. Answers route through the same service method the web feed uses, so a choice answer lands as human comparison observations (judge\_slug human:\) in the calibration ledger, and any flagged-broken variants land as 'defect' observations. # RestorePageAPI mirrors the erdo_restore_page MCP tool. Source: https://docs.erdo.ai/api-reference/restorepageapi-mirrors-the-erdo_restore_page-mcp-tool /api/openapi.json post /v1/pages/restore/{pageID} The path is /v1/pages/restore/:pageID rather than /v1/pages/:pageID/restore because Encore forbids a parameterized segment (:pageID) at the same position as the existing static /v1/pages/validate route under POST. A static "restore" sibling to "validate" sidesteps that; delete stays RESTful (DELETE /v1/pages/:pageID) since DELETE has no static sibling at that position. # RevokeConnectLinkAPI mirrors erdo_revoke_integration_connect_link. Source: https://docs.erdo.ai/api-reference/revokeconnectlinkapi-mirrors-erdo_revoke_integration_connect_link /api/openapi.json post /v1/integration-connect-links/{id}/revoke # RevokeManagedOrganizationAPI mirrors erdo_revoke_managed_organization (managed org named by slug). Source: https://docs.erdo.ai/api-reference/revokemanagedorganizationapi-mirrors-erdo_revoke_managed_organization-managedorg-named-by-slug /api/openapi.json delete /v1/managed-organizations/{orgSlug} # RevokeTokenAPI revokes one of the caller's API tokens. Source: https://docs.erdo.ai/api-reference/revoketokenapi-revokes-one-of-the-callers-api-tokens /api/openapi.json delete /v1/tokens/{tokenID} # RotateEventPipelineSecretAPI mirrors erdo_rotate_event_pipeline_secret. The Source: https://docs.erdo.ai/api-reference/rotateeventpipelinesecretapi-mirrors-erdo_rotate_event_pipeline_secret-the /api/openapi.json post /v1/event-pipelines/{pipelineSlug}/rotate-secret new secret is returned exactly once, on this response; the old secret stops verifying immediately. # RunEvalSuiteAPI mirrors erdo_run_eval_suite. Source: https://docs.erdo.ai/api-reference/runevalsuiteapi-mirrors-erdo_run_eval_suite /api/openapi.json post /v1/evals/suites/{suiteSlug}/run # RunHeartbeatAPI mirrors the erdo_run_heartbeat MCP tool. Source: https://docs.erdo.ai/api-reference/runheartbeatapi-mirrors-the-erdo_run_heartbeat-mcp-tool /api/openapi.json post /v1/heartbeats/{heartbeatID}/run # RunPersonaPanelAPI mirrors erdo_run_persona_panel: start a persona panel Source: https://docs.erdo.ai/api-reference/runpersonapanelapi-mirrors-erdo_run_persona_panel:-start-a-persona-panel /api/openapi.json post /v1/experiments/{experimentSlug}/panel (wind tunnel) against an experiment's page variants. Results land as experiment observations, readable via GET /v1/experiments/:slug/observations. # RunQueryAPI mirrors the erdo_run_query MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/runqueryapi-mirrors-the-erdo_run_query-mcp-tool-as-a-rest-endpoint /api/openapi.json post /v1/datasets/{datasetSlug}/query # RunTournamentAPI mirrors erdo_run_pairwise_tournament: rank an experiment's Source: https://docs.erdo.ai/api-reference/runtournamentapi-mirrors-erdo_run_pairwise_tournament:-rank-an-experiments /api/openapi.json post /v1/experiments/{experimentSlug}/tournament page variants pairwise and persist the comparisons to the ledger. Synchronous — each judged pair is one LLM round-trip, so expect minutes. # ScreenshotAPI mirrors the erdo_screenshot MCP tool as a REST endpoint: capture Source: https://docs.erdo.ai/api-reference/screenshotapi-mirrors-the-erdo_screenshot-mcp-tool-as-a-rest-endpoint:-capture /api/openapi.json post /v1/screenshot a public URL to a PNG and return a signed download URL. # ScreenshotResultAPI polls an instructed capture started by ScreenshotAPI, Source: https://docs.erdo.ai/api-reference/screenshotresultapi-polls-an-instructed-capture-started-by-screenshotapi /api/openapi.json post /v1/screenshot/result returning status="processing" until the detached browser render finishes. # SearchDatasetsAPI mirrors the erdo_search_datasets MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/searchdatasetsapi-mirrors-the-erdo_search_datasets-mcp-tool-as-a-rest-endpoint /api/openapi.json get /v1/datasets-search # SearchIntegrationAppsAPI mirrors the erdo_search_integration_apps MCP tool. Source: https://docs.erdo.ai/api-reference/searchintegrationappsapi-mirrors-the-erdo_search_integration_apps-mcp-tool /api/openapi.json get /v1/integration-apps # SearchKnowledgeAPI mirrors the erdo_search_knowledge MCP tool. Source: https://docs.erdo.ai/api-reference/searchknowledgeapi-mirrors-the-erdo_search_knowledge-mcp-tool /api/openapi.json get /v1/knowledge-search # SendMessageAPI mirrors the erdo_send_message MCP tool. Source: https://docs.erdo.ai/api-reference/sendmessageapi-mirrors-the-erdo_send_message-mcp-tool /api/openapi.json post /v1/threads/{threadID}/send # SetAutonomyGatesAPI mirrors erdo_set_autonomy_gates: replace the org's Source: https://docs.erdo.ai/api-reference/setautonomygatesapi-mirrors-erdo_set_autonomy_gates:-replace-the-orgs /api/openapi.json put /v1/autonomy-gates risk-tiered approval gate list (admin only, enforced by the organization service). Send {"gates": null} to restore the legacy gate-everything behavior. # SetAutonomyModeAPI mirrors erdo_set_autonomy_mode: set the org's engine Source: https://docs.erdo.ai/api-reference/setautonomymodeapi-mirrors-erdo_set_autonomy_mode:-set-the-orgs-engine /api/openapi.json put /v1/autonomy-mode autonomy mode. Admin only (enforced by the organization service). # SetConnectionScopeAPI mirrors the erdo_set_connection_scope MCP tool. Source: https://docs.erdo.ai/api-reference/setconnectionscopeapi-mirrors-the-erdo_set_connection_scope-mcp-tool /api/openapi.json post /v1/integrations/{integrationID}/connection-scope # SetDatasetNotificationAPI replaces the recipient list for a dataset. An empty Source: https://docs.erdo.ai/api-reference/setdatasetnotificationapi-replaces-the-recipient-list-for-a-dataset-an-empty /api/openapi.json put /v1/dataset-notifications list turns notifications off. # SetHeartbeatStateAPI mirrors the erdo_set_heartbeat_state MCP tool. Source: https://docs.erdo.ai/api-reference/setheartbeatstateapi-mirrors-the-erdo_set_heartbeat_state-mcp-tool /api/openapi.json post /v1/heartbeats/{heartbeatID}/state # SetIntegrationIdentityAPI writes a connection's stored account identity Source: https://docs.erdo.ai/api-reference/setintegrationidentityapi-writes-a-connections-stored-account-identity /api/openapi.json post /v1/integrations/{integrationID}/identity (account\_id, and for Google Ads agency connections the manager/MCC login\_customer\_id) into its encrypted credentials, leaving every other credential key untouched, and returns the integration's summary in the GET /v1/integrations shape — never any token material. This is the escape hatch the OAuth callback's auto-detection defers to: the detector only stamps login\_customer\_id when a fresh token's sole accessible account is a manager, and every ambiguous shape needs the id set explicitly afterwards. The internal UpdateIntegration surface that used to be the documented way to do that is session-only, so a /v1 consumer had no way to set the id a manager connection hard-requires (client-account requests, managed-container provisioning) or to backfill account\_id onto connections that predate identity stamping. # SetKnowledgeVisibilityAPI mirrors the erdo_set_knowledge_visibility MCP tool. Source: https://docs.erdo.ai/api-reference/setknowledgevisibilityapi-mirrors-the-erdo_set_knowledge_visibility-mcp-tool /api/openapi.json patch /v1/knowledge/{knowledgeObjectID}/visibility # SetKVItemAPIv1 mirrors the erdo_set_kv_item MCP tool. Source: https://docs.erdo.ai/api-reference/setkvitemapiv1-mirrors-the-erdo_set_kv_item-mcp-tool /api/openapi.json put /v1/kv/{slug}/items/{key} # SetPaidMediaCampaignBudgetAPI mirrors erdo_set_paid_media_campaign_budget: Source: https://docs.erdo.ai/api-reference/setpaidmediacampaignbudgetapi-mirrors-erdo_set_paid_media_campaign_budget: /api/openapi.json post /v1/paid-media/campaigns/{externalID}/budget change a provider campaign's daily budget (micros) through the approval gate, with the same propose/execute semantics as the status endpoint. # SetPaidMediaCampaignStatusAPI mirrors erdo_set_paid_media_campaign_status: Source: https://docs.erdo.ai/api-reference/setpaidmediacampaignstatusapi-mirrors-erdo_set_paid_media_campaign_status: /api/openapi.json post /v1/paid-media/campaigns/{externalID}/status pause or enable a provider campaign through the approval gate. Without a standing always-approve policy the response is pending\_approval + the approval request id (202-style: accepted, not performed); with one, the provider call is made and its result returned. # SetWorkstreamExternalRefsAPI declares which provider-owned objects a Source: https://docs.erdo.ai/api-reference/setworkstreamexternalrefsapi-declares-which-provider-owned-objects-a /api/openapi.json put /v1/workstreams/{workstreamSlug}/external-refs workstream governs — the machine-readable replacement for a campaign allowlist hand-typed into a knowledge record. REST only, with no MCP mirror, and the asymmetry is the point: an agent must be able to READ the set of live campaigns it may act on, and must not be able to widen it. The consumer holding the mapping declares scope here; agents read it back on every workstream and ledger read. # TransferCustomDomainAPI moves a custom domain to another organization the Source: https://docs.erdo.ai/api-reference/transfercustomdomainapi-moves-a-custom-domain-to-another-organization-the /api/openapi.json post /v1/custom-domains/{domain}/transfer caller also administers, without touching its CDN hostname or certificate — the serving cutover is atomic with the move and incurs no re-validation downtime. # UpdateDatasetSchemaAPI updates a dataset's schema (add/remove/rename columns, Source: https://docs.erdo.ai/api-reference/updatedatasetschemaapi-updates-a-datasets-schema-addremoverename-columns /api/openapi.json post /v1/datasets/{datasetSlug}/schema alter types) and/or its class ('table' or 'scratch' via the \`class\` field). # UpdateEvalCaseAPI mirrors erdo_update_eval_case. Source: https://docs.erdo.ai/api-reference/updateevalcaseapi-mirrors-erdo_update_eval_case /api/openapi.json put /v1/evals/suites/{suiteSlug}/cases/{caseName} # UpdateEvalSuiteAPI mirrors erdo_update_eval_suite. Only provided fields change. Source: https://docs.erdo.ai/api-reference/updateevalsuiteapi-mirrors-erdo_update_eval_suite-only-provided-fields-change /api/openapi.json put /v1/evals/suites/{suiteSlug} # UpdateEventPipelineAPI mirrors erdo_update_event_pipeline. Fields merge over Source: https://docs.erdo.ai/api-reference/updateeventpipelineapi-mirrors-erdo_update_event_pipeline-fields-merge-over /api/openapi.json put /v1/event-pipelines/{pipelineSlug} the stored pipeline — an omitted field keeps its current value — so a caller can edit just the steps or the state without re-sending the rest. # UpdateExperimentAPI mirrors erdo_update_experiment. Source: https://docs.erdo.ai/api-reference/updateexperimentapi-mirrors-erdo_update_experiment /api/openapi.json patch /v1/experiments/{experimentSlug} # UpdateHeartbeatAPI mirrors the erdo_update_heartbeat MCP tool. Source: https://docs.erdo.ai/api-reference/updateheartbeatapi-mirrors-the-erdo_update_heartbeat-mcp-tool /api/openapi.json patch /v1/heartbeats/{heartbeatID} # UpdatePageAPI mirrors the erdo_update_page MCP tool. Source: https://docs.erdo.ai/api-reference/updatepageapi-mirrors-the-erdo_update_page-mcp-tool /api/openapi.json put /v1/pages/{pageID} # UpdateSendingDomainAPI edits a sending domain's identity settings — from Source: https://docs.erdo.ai/api-reference/updatesendingdomainapi-edits-a-sending-domains-identity-settings-—-from /api/openapi.json patch /v1/sending-domains/{domain} name, mailbox part, forwarding mailbox, receiving — named by the domain itself. Absent fields are left unchanged. Mirrors erdo\_update\_sending\_domain. # UpdateWorkStateAPI mirrors erdo_update_work_state. Source: https://docs.erdo.ai/api-reference/updateworkstateapi-mirrors-erdo_update_work_state /api/openapi.json patch /v1/workstreams/{workstreamSlug}/state # UploadDatasetFileAPI mirrors the erdo_upload_dataset_file MCP tool as a REST Source: https://docs.erdo.ai/api-reference/uploaddatasetfileapi-mirrors-the-erdo_upload_dataset_file-mcp-tool-as-a-rest /api/openapi.json post /v1/datasets-upload endpoint. (/v1/datasets/upload would conflict with the /v1/datasets/:datasetID routes, hence the hyphenated path — same convention as /v1/datasets-search.) # UploadImageAPI mirrors the erdo_upload_image MCP tool as a REST endpoint. Source: https://docs.erdo.ai/api-reference/uploadimageapi-mirrors-the-erdo_upload_image-mcp-tool-as-a-rest-endpoint /api/openapi.json post /v1/images/upload Accepts a base64-encoded image in the request body and returns the bucket key that can be passed to AskDataQuestionAPI / erdo\_ask\_data\_question. # ValidatePageAPI mirrors the erdo_validate_page MCP tool. Source: https://docs.erdo.ai/api-reference/validatepageapi-mirrors-the-erdo_validate_page-mcp-tool /api/openapi.json post /v1/pages/validate # WriteDatasetRowsAPI writes/upserts rows to a dataset. Source: https://docs.erdo.ai/api-reference/writedatasetrowsapi-writesupserts-rows-to-a-dataset /api/openapi.json post /v1/datasets/{datasetSlug}/rows # REST API Source: https://docs.erdo.ai/api/overview Use Erdo's REST API to query, write, and manage your data programmatically # REST API Erdo's REST API lets you integrate your data platform into any application. Query datasets, write data, manage conversations, create automations, and more — all via standard HTTP requests. **Base URL:** `https://api.erdo.ai` **OpenAPI spec:** the **API Reference** section in the sidebar is generated from the full machine-readable OpenAPI spec — every `/v1` endpoint with its request and response schemas. Point a code generator or a coding agent at the spec to build a typed client. **Authentication:** All requests require a Bearer token in the `Authorization` header. ```bash theme={null} curl https://api.erdo.ai/v1/datasets \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Getting an API Key Click your profile in the bottom-left corner of Erdo and go to **API Keys**. Create a new key and copy the token — it's shown only once. An API key is an **account-level credential**: it acts as *you*, and works in any organization you're a member of. The organization stored on the key is only its **default org** — the one used when a request doesn't name one. It is not a hard scope. To act in a specific organization, send the `X-Organization-ID` header with the org's id or slug. The backend validates on every request that you're a member of that org, so a key can never reach an org you don't belong to: ```bash theme={null} # Uses the key's default org curl https://api.erdo.ai/v1/datasets \ -H "Authorization: Bearer YOUR_API_KEY" # Targets a specific org you belong to curl https://api.erdo.ai/v1/datasets \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Organization-ID: acme" ``` Manage keys from the [CLI](/cli#api-tokens) too: `erdo token create --name ci`, `erdo token list`, `erdo token revoke `. Set a command's org with `erdo --org acme ` or change the active default with `erdo org use acme`. ### Project context Projects are optional work context inside an organization. To run a request in one, send its UUID as `X-Project-ID` together with the organization header: ```bash theme={null} curl https://api.erdo.ai/v1/datasets \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Organization-ID: acme" \ -H "X-Project-ID: 01234567-89ab-cdef-0123-456789abcdef" ``` The project must belong to the active organization. Erdo rejects an invalid, inaccessible, or cross-organization project on authenticated API and MCP requests rather than silently falling back to organization-wide scope. Project-aware list endpoints such as datasets and conversations narrow to resources attached to the project; new conversations, datasets, and workflow outputs are attached when the caller has contributor access. Endpoints that are explicitly organization-wide remain organization-wide. List IDs with `GET /v1/projects` (or `erdo project list`) and pass the same context to the CLI with `erdo --org acme --project `. ### Scoped API Keys A server that only needs part of the API should not hold a key that can do everything. Mint a [scoped API key](/api/scoped-keys) with a capability allowlist (e.g. `datasets:query`) and optional dataset/workstream slugs — every endpoint outside its grants is denied, and no capability can mint keys, touch managed organizations, or connect/delete integrations. ### Scoped Tokens For building apps where your end-users interact with Erdo, use [scoped tokens](/mcp/overview#scoped-tokens--external-users) to restrict access to specific datasets and threads. ### API Surface API keys and scoped tokens work on the documented API surface: every `/v1` endpoint, the `/mcp` endpoint, and the endpoints used by the published SDKs (scoped-token minting, thread messaging, agent invocation, dataset reads). Requests to any other path return `403 permission_denied` — other routes are internal to the Erdo app and not a stable contract to build against. If you need a capability that isn't on `/v1` yet, tell us rather than coupling to an internal route. *** ## Datasets ### List Datasets ``` GET /v1/datasets ``` Returns all datasets in your organization. | Parameter | Type | Description | | --------- | ------ | --------------------------------- | | `limit` | number | Max results (default 20, max 100) | | `offset` | number | Pagination offset | Each thread includes its canonical title, creator ID, creator name and email when that user is still in the organization, and its creation source. New threads without an explicit name are titled from their first user message. ```bash theme={null} curl "https://api.erdo.ai/v1/datasets?limit=10" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "datasets": [ { "id": "uuid", "slug": "my-org.sales-data", "name": "Sales Data", "description": "Monthly sales figures", "type": "file", "status": "active" } ], "total": 42 } ``` ### Configure an Integration Dataset ``` POST /v1/integration-datasets/{dataset_id}/configure ``` Update an existing integration dataset using the same generic segments returned by Discover Tables. Setting `enable_sync` materializes sync-capable integrations through Erdo's canonical data platform; it does not create a snapshot dataset. | Parameter | Type | Description | | ------------- | --------- | ---------------------------------------------------------- | | `segments` | string\[] | Segment names or ids to include | | `enable_sync` | boolean | Enable canonical data-platform sync after saving the scope | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/integration-datasets/DATASET_ID/configure" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"segments": ["ACCOUNT_OR_SCHEMA_ID"], "enable_sync": true}' ``` ### Create Dataset ``` POST /v1/datasets-create ``` Create a new empty dataset. Uses your organization's default storage backend. After creation, use [Write Rows](#write-rows) to add data. | Parameter | Type | Description | | -------------- | ------ | ---------------------------------------------------------- | | `name` | string | Name for the dataset | | `description` | string | Optional description | | `instructions` | string | Optional instructions for AI agents analyzing this dataset | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/datasets-create" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Daily Metrics", "description": "Automated metrics from our monitoring pipeline"}' ``` ```json theme={null} { "id": "uuid", "slug": "my-org.daily-metrics", "name": "Daily Metrics", "description": "Automated metrics from our monitoring pipeline", "type": "file", "status": "active" } ``` ### Upload a File as a Dataset ``` POST /v1/datasets-upload ``` Upload a file and create a dataset from it in one call. The file's schema is extracted before the response returns, so the dataset is immediately queryable. Prefer this over Create Dataset + Write Rows when your data already exists as a file. | Parameter | Type | Description | | ---------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- | | `filename` | string | Filename with extension — drives type detection (`.csv`, `.tsv`, `.xlsx`, `.json`, `.jsonl`, `.pdf`, `.docx`, `.txt`, `.md`, ...) | | `content_base64` | string | The raw file bytes, base64-encoded (max 20 MB decoded; use the web app's resumable upload for larger files) | | `name` | string | Optional display name; defaults to the filename | | `description` | string | Optional description shown to AI agents analyzing the dataset | ```bash theme={null} # base64 -w0 keeps the output on one line (GNU coreutils wraps at 76 cols by # default, which would break the JSON string); on macOS `base64 -i leads.csv`. curl -X POST "https://api.erdo.ai/v1/datasets-upload" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"filename\": \"leads.csv\", \"name\": \"Leads\", \"content_base64\": \"$(base64 -w0 leads.csv)\"}" ``` `ready` reports whether schema extraction succeeded; when `false`, the file stored but is not yet queryable (a `warning` explains why). ```json theme={null} { "dataset_id": "uuid", "slug": "my-org.leads", "name": "Leads", "ready": true } ``` ### Delete Dataset ``` DELETE /v1/datasets/:slug ``` Permanently delete a dataset and all its data. Requires admin permission on the dataset. ```bash theme={null} curl -X DELETE "https://api.erdo.ai/v1/datasets/my-org.daily-metrics" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Search Datasets ``` GET /v1/datasets-search ``` Search datasets by name. | Parameter | Type | Description | | --------- | ------ | ----------- | | `query` | string | Search text | ```bash theme={null} curl "https://api.erdo.ai/v1/datasets-search?query=revenue" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Get Dataset Schema ``` GET /v1/datasets/:id/schema ``` Get detailed schema for a dataset including column names, types, statistics, and sample data. Call this before writing data to understand the column structure. ```bash theme={null} curl "https://api.erdo.ai/v1/datasets/my-dataset-uuid/schema" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### Query Dataset (SQL) ``` POST /v1/datasets/:slug/query ``` Run a SQL query against a dataset. The SQL dialect depends on the storage backend (PostgreSQL, ClickHouse, or DuckDB for file datasets). | Parameter | Type | Description | | -------------- | ------ | --------------------------------------------------------------------------------- | | `query` | string | SQL query to execute | | `resource_key` | string | Resource key from the dataset schema; selects a table in a multi-resource dataset | | `limit` | number | Max rows (default 100) | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/datasets/my-org.sales-data/query" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"resource_key": "orders", "query": "SELECT * FROM orders WHERE revenue > 10000 ORDER BY date DESC", "limit": 50}' ``` ```json theme={null} { "columns": ["date", "revenue", "orders"], "rows": [["2025-03-15", "42300", "156"], ["2025-03-14", "38900", "142"]], "row_count": 2 } ``` For file datasets (CSV/Excel), the table is always named `data` regardless of the resource key. For database, warehouse, and synchronized API integration datasets, use the actual resource or table name from the schema — passing `resource_key` selects which resource to query, and that resource's name is the SQL table name. ### Query Dataset (Natural Language) ``` POST /v1/datasets/:slug/query-nl ``` Query a dataset using natural language. Erdo generates and executes the correct SQL for you. | Parameter | Type | Description | | ---------- | ------ | ------------------------- | | `question` | string | Natural language question | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/datasets/my-org.sales-data/query-nl" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "What were the top 5 products by revenue last month?"}' ``` The response carries both the SQL Erdo wrote and what it returned: ```json theme={null} { "sql": "SELECT product, SUM(amount) AS revenue FROM data GROUP BY 1 ORDER BY 2 DESC LIMIT 5", "columns": ["product", "revenue"], "rows": [["Widget", "48210"], ["Gizmo", "31775"]], "row_count": 2, "output": "Queried my-org.sales-data — 2 rows returned. ...", "success": true } ``` `rows` holds the values in `columns` order — the same tabular shape `/v1/datasets/:slug/fetch` returns, so both dataset reads are consumed the same way — and `output` is that result rendered to read. `row_count` is how many rows the query matched and can exceed the number of rows returned, which is capped at 1000; compare the two to tell whether you have the whole result. When the dataset's saved filters shaped the read, `applied_filters` names them, so a count is never reported as the whole truth when it excludes something. On failure, `success` is `false` and `error` says why. ### Fetch Dataset Contents ``` POST /v1/datasets/:slug/fetch ``` Fetch raw contents of a dataset without writing SQL. | Parameter | Type | Description | | ----------- | ------ | ----------------------- | | `limit` | number | Max rows (default 1000) | | `sql_query` | string | Optional SQL filter | ### Get Dataset Context ``` GET /v1/dataset-context ``` Get detailed context for multiple datasets at once — schemas, column types, statistics, and sample data. | Parameter | Type | Description | | --------------- | --------- | ------------------------------------ | | `dataset_slugs` | string\[] | Specific dataset slugs (empty = all) | | `limit` | number | Max datasets (default 10) | *** ## Writing Data Write data into your datasets from any application. Rows are written to whatever storage backend the dataset uses (Postgres, ClickHouse, or CSV file storage). ### Write Rows ``` POST /v1/datasets/:slug/rows ``` Write or upsert rows to a dataset. | Parameter | Type | Description | | ------------ | --------- | ----------------------------------------------------------- | | `rows` | object\[] | Array of row objects with column names as keys | | `key_column` | string | Optional. Column to upsert on (update existing, insert new) | **Append rows (no key column):** ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/datasets/my-org.metrics/rows" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rows": [ {"date": "2025-03-17", "revenue": 42300, "orders": 156}, {"date": "2025-03-18", "revenue": 45100, "orders": 163} ] }' ``` **Upsert rows (with key column):** ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/datasets/my-org.metrics/rows" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rows": [ {"date": "2025-03-17", "revenue": 43500, "orders": 160} ], "key_column": "date" }' ``` ```json theme={null} { "rows_affected": 2, "rows_inserted": 1, "rows_updated": 1 } ``` `rows_inserted` and `rows_updated` split `rows_affected` into the rows that created a row in the dataset and the rows that replaced one. A keyed write is an upsert, so this is the only way to tell a new record from a correction to one you already had — an arrival timestamp cannot say, because an upsert rewrites the row it matched and the timestamp moves with it. Rows also carry `erdo_created_at`, set when a write creates the row and left alone by every write after, for when you come back to the dataset later rather than reading the response. **Storage-specific behavior:** | Storage | Append | Upsert (`key_column`) | Notes | | -------------- | ------------- | ----------------------------------------- | ---------------------------------------- | | **Postgres** | Insert rows | `ON CONFLICT DO UPDATE` | Key column must have a unique constraint | | **ClickHouse** | Batch insert | Insert (use ReplacingMergeTree for dedup) | | | **CSV/File** | Append to CSV | Not supported (always appends) | New columns are added automatically | ### Delete Rows ``` DELETE /v1/datasets/:slug/rows ``` Delete rows from a dataset. Works for both file (CSV) and database-backed datasets. | Parameter | Type | Description | | ------------ | --------- | ------------------------------------------ | | `key_column` | string | Column to match against | | `keys` | string\[] | Values to delete. Empty = delete all rows. | ```bash theme={null} curl -X DELETE "https://api.erdo.ai/v1/datasets/my-org.metrics/rows" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key_column": "date", "keys": ["2025-03-17"]}' ``` ```json theme={null} { "rows_affected": 1 } ``` ### Update Schema ``` POST /v1/datasets/:slug/schema ``` Update a dataset's schema: add, remove, rename columns, or change column types. Operations are applied atomically — if any fails, none are applied. Supported for CSV file datasets only. After changes, column analysis is automatically refreshed. | Parameter | Type | Description | | ------------ | --------- | -------------------------- | | `operations` | object\[] | Array of schema operations | Each operation object: | Field | Type | Description | | ------------- | ------ | --------------------------------------------------------------------------------------------------- | | `type` | string | `add_column`, `remove_column`, `rename_column`, or `alter_column_type` | | `column` | string | Target column name | | `new_name` | string | New name (for `rename_column` only) | | `column_type` | string | Type hint: `text`, `integer`, `float`, `date`, `boolean` (for `add_column` and `alter_column_type`) | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/datasets/my-org.metrics/schema" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "operations": [ {"type": "add_column", "column": "region", "column_type": "text"}, {"type": "rename_column", "column": "rev", "new_name": "revenue"}, {"type": "alter_column_type", "column": "revenue", "column_type": "float"}, {"type": "remove_column", "column": "temp_notes"} ] }' ``` ```json theme={null} { "columns_added": 1, "columns_removed": 1, "columns_renamed": 1, "columns_retyped": 1, "current_columns": ["date", "revenue", "region"] } ``` *** ## Ask Questions ### Ask a Data Question ``` POST /v1/ask ``` Ask a natural language question about your data. Invokes an AI agent that analyzes datasets, writes code, and returns a text answer. Can take 30 seconds to 2 minutes for complex questions. | Parameter | Type | Description | | --------------- | --------- | ------------------------------------- | | `question` | string | The data question | | `dataset_slugs` | string\[] | Optional. Scope to specific datasets. | | `timezone` | string | Optional. e.g. `America/New_York` | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/ask" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "What were total sales last quarter?", "dataset_slugs": ["my-org.sales-data"]}' ``` ```json theme={null} { "thread_id": "uuid", "status": "success", "answer": "Total sales last quarter were $1.2M, up 15% from the previous quarter..." } ``` *** ## Integrations Connect third-party apps and data sources so Erdo can use them — in agent runs, data questions, and datasets. What decides the flow is whether you already hold the credential: databases, API keys, and service accounts connect in a single call, and so do SaaS apps whose auth type is `keys`. OAuth apps are the exception, because the provider mints their credentials during the authorization itself — they return a `connect_url` for the user to authorize in a browser, and polling the status endpoint completes the connection. [Connecting integrations](/integrations) covers both flows end to end. ### List Integrations ``` GET /v1/integrations ``` Returns the integrations connected in your organization. ```bash theme={null} curl "https://api.erdo.ai/v1/integrations" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "integrations": [ { "id": "uuid", "app": "postgres", "name": "Production DB", "status": "active", "auth_type": "database", "type": "database" } ] } ``` ### Search Connectable Apps ``` GET /v1/integration-apps?query=slack ``` Search apps that can be connected: native integrations (databases, warehouses, APIs) and thousands of SaaS apps. The `auth_types` field tells you how connection works — `database`, `api_key`, `service_account`, `basic`, and `aws_iam` on a native integration, plus `keys` on a SaaS app, all connect directly with credentials, while `oauth`/`oauth2`/`oauth1` apps go through a browser authorization step. | Parameter | Type | Description | | --------- | ------ | ------------------------------------------------------------------------ | | `query` | string | Optional search term. Empty lists native integrations plus popular apps. | ```json theme={null} { "apps": [ { "app": "slack", "name": "Slack", "auth_types": ["oauth"], "source": "pipedream" }, { "app": "postgres", "name": "PostgreSQL", "auth_types": ["database"], "source": "native" } ] } ``` ### Connect an Integration ``` POST /v1/integrations-connect ``` | Parameter | Type | Description | | ------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `app` | string | App identifier from [Search Connectable Apps](#search-connectable-apps) | | `name` | string | Optional display name for the connection | | `credentials` | object | Credentials keyed by the field names the app declares. Works for native credential integrations and for SaaS apps whose auth type is `keys`. Passing them to an OAuth app is rejected with an error — connect without them and use the returned `connect_url`. | | `scopes` | array | Optional scopes to grant; defaults to all the app supports | | `return_url` | string | Optional absolute `http(s)` URL to send the user's browser to after they authorize an OAuth app at `connect_url`. Defaults to Erdo's data page; pass your own page when you embed the connect flow in your product so the user lands back on it. Ignored for credential-based apps that connect in one call. | Connecting a database — created, verified, and activated in one call: ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/integrations-connect" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "app": "postgres", "credentials": { "host": "db.example.com", "port": "5432", "database": "analytics", "username": "readonly", "password": "..." } }' ``` ```json theme={null} { "app": "postgres", "integration_id": "uuid", "status": "active" } ``` Connecting an OAuth app — the response carries a `connect_url` to open in a browser. Pass `return_url` when you embed the flow in your own product so the user's browser lands back on your page (not Erdo's) after authorizing: ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/integrations-connect" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"app": "slack", "return_url": "https://portal.example.com/integrations/done"}' ``` ```json theme={null} { "app": "slack", "status": "pending", "connect_url": "https://...", "next_step": "Have the user open connect_url in a browser and authorize slack, then check the connection status for app=\"slack\" to confirm." } ``` If verification fails for a native credential integration (wrong password, unreachable host), the call returns an error and nothing is left behind — fix the credentials and retry. A SaaS `keys` app connects the same way but is **not** verified: its credentials go to the connector platform, which stores what it is given without calling the vendor. The response says so in `next_step`, and the first action run is what confirms the key. ### Check Connection Status ``` GET /v1/integrations-connect/:app ``` Reports whether an app is connected. For browser-authorized apps this also completes any connection the user finished since the connect call — poll it after the user opens the `connect_url`. An app you have never connected answers `{"app": "...", "connected": false, "integrations": []}`. That is an ordinary answer about your account, not an error, so a caller starting from zero can poll it safely; a genuine provider failure still comes back as an error. ```bash theme={null} curl "https://api.erdo.ai/v1/integrations-connect/slack" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "app": "slack", "connected": true, "integrations": [ { "id": "uuid", "app": "slack", "name": "Slack (team@acme.com)", "status": "connected" } ], "note": "1 new connection(s) confirmed." } ``` ### Delete an Integration ``` DELETE /v1/integrations/:id ``` Removes a connected integration by its id (from [List Integrations](#list-integrations)). The credential is deleted and any datasets built on the integration are cleaned up — the same behaviour as removing the connection in the Erdo web app. This is how you correct a connection made in the wrong organization without a trip to the UI. An id that does not exist in your organization — including one already deleted — answers `404 Not Found`. ```bash theme={null} curl -X DELETE "https://api.erdo.ai/v1/integrations/INTEGRATION_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "id": "uuid", "app": "reddit_ads", "name": "Reddit Ads Integration", "deleted": true } ``` ### Discover Tables ``` GET /v1/integrations/:integration/tables ``` Discover what a connected database integration exposes. `:integration` is the app key (e.g. `postgres`) or, when several instances of the same app are connected, the integration id. Without `schema_name` it lists the selectable schemas (all SQL databases and warehouses). With `schema_name` it lists that schema's tables with columns, types, and row estimates — supported for SQL databases (Postgres, MySQL, and compatible); warehouses (BigQuery, Snowflake, ClickHouse) list schemas but not per-table columns here. | Parameter | Type | Description | | ------------- | ------ | ------------------------------------ | | `schema_name` | string | Optional. Schema to list tables for. | ```bash theme={null} curl "https://api.erdo.ai/v1/integrations/postgres/tables?schema_name=public" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "integration_id": "uuid", "app": "postgres", "schemas": [{ "id": "public", "name": "public", "type": "schema" }], "tables": [ { "schema_name": "public", "table_name": "orders", "columns": [{ "name": "id", "data_type": "uuid", "is_nullable": false, "is_primary_key": true }], "estimated_row_count": 12840 } ] } ``` ### Create a Dataset from an Integration ``` POST /v1/integration-datasets ``` Create a dataset backed by any connected integration that exposes queryable data. Database and warehouse integrations can query live against the source; sync-capable API integrations automatically materialize their provider resources through Erdo's data platform. Pick selectable scopes with Discover Tables first when the integration exposes them (some allow only one). Integrations with neither a direct query path nor dataset sync are rejected. Resource and column discovery runs in the background and the dataset schema appears after the first discovery/sync, after which [Query Dataset](#query-dataset-sql) works against the returned slug. | Parameter | Type | Description | | ------------- | --------- | ----------------------------------------------------------------- | | `integration` | string | App key (e.g. `postgres`) or integration id | | `name` | string | Display name for the dataset | | `description` | string | Optional description shown to AI agents | | `schemas` | string\[] | Selectable schema names (or ids) to include, from Discover Tables | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/integration-datasets" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"integration": "postgres", "name": "Production DB", "schemas": ["public"]}' ``` ```json theme={null} { "dataset_id": "uuid", "slug": "my-org.production-db", "name": "Production DB", "status": "active" } ``` *** ## Threads & Conversations ### List Threads ``` GET /v1/threads ``` | Parameter | Type | Description | | --------- | ------ | ------------------------ | | `limit` | number | Max results (default 20) | | `offset` | number | Pagination offset | ### Get Thread Messages ``` GET /v1/threads/:id/messages ``` Get all messages from a conversation thread. User-authored messages include the author's canonical ID, name, and email when that user is still in the organization. Each content item includes its persisted `content_type` and, when present, `ui_content_type`. Use `ui_content_type` to select the presentation component for structured content such as tool results and generated UI. UI-generation items also include `created_by_invocation_id` when the renderer needs the originating invocation to load referenced data. ### Create Thread ``` POST /v1/threads-create ``` | Parameter | Type | Description | | ------------- | --------- | -------------------------------- | | `name` | string | Optional thread name | | `dataset_ids` | string\[] | Optional dataset UUIDs to attach | ### Send Message ``` POST /v1/threads/:id/send ``` Send a message to a thread and get an AI-generated response. The optional `context` is available to the agent for this turn without becoming part of the visible user message in the thread. This lets an application supply the current page, selection, or other structured state while preserving an honest transcript. The persisted message is attributed to the authenticated user, including when the request comes through an application using the REST or MCP surface. Can take 30 seconds to 2 minutes. | Parameter | Type | Description | | ----------- | ------- | ----------------------------------------------------------------------------------------- | | `message` | string | The message | | `context` | string | Optional application context for this turn. It is not stored as the visible user message. | | `agent_key` | string | Optional. Default: `erdo.data-question-answerer` | | `timezone` | string | Optional timezone | | `async` | boolean | Optional. Start the run and return immediately. | *** ## Memories & Skills Memories store reusable knowledge and instructions that Erdo's AI uses in future conversations. ### Create Memory ``` POST /v1/memories ``` | Parameter | Type | Description | | ------------- | --------- | ----------------------------------------------- | | `title` | string | Short title | | `content` | string | The content or instructions | | `description` | string | Brief description | | `type` | string | `snippet` (knowledge) or `skill` (instructions) | | `category` | string | Optional category | | `tags` | string\[] | Optional tags | | `dataset_ids` | string\[] | Optional associated dataset UUIDs | ### Search Memories ``` GET /v1/memories-search ``` | Parameter | Type | Description | | --------- | ------ | ------------------------ | | `query` | string | Search text | | `limit` | number | Max results (default 10) | ### List Memories ``` GET /v1/memories ``` | Parameter | Type | Description | | ---------- | ------ | ------------------------------------- | | `type` | string | Optional filter: `snippet` or `skill` | | `category` | string | Optional category filter | | `limit` | number | Max results (default 20) | | `offset` | number | Pagination offset | ### Delete Memory ``` DELETE /v1/memories/:id ``` Soft delete — can be recovered. *** ## Artifacts Artifacts are AI-generated outputs from agent runs — insights, metrics, alerts, and tables. ### List Artifacts ``` GET /v1/artifacts ``` | Parameter | Type | Description | | --------- | ------ | ---------------------------------------------------------------------- | | `type` | string | Optional: `insight`, `chart`, `metric`, `alert`, `table`, `suggestion` | | `limit` | number | Max results (default 20) | | `offset` | number | Pagination offset | ### Get Artifact ``` GET /v1/artifacts/:id ``` *** ## Screenshots Capture a **public** web page to a PNG and get a signed, time-limited download URL. Only works on URLs reachable without login (a marketing site, a published Erdo page at `https://pages.erdo.ai/p/{id}`, etc.). ``` POST /v1/screenshot ``` ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/screenshot" \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://pages.erdo.ai/p/abc123", "full_page": true}' ``` Response: `signed_url`, `bucket_key`, `media_type`, `width`, `height`, `expires_at`. *** ## Pages Deploy authenticated, data-wired HTML apps to Erdo over HTTP — the same surface as the `erdo_deploy_page` MCP tools, for CI and scripts. See the [Build Apps on Erdo](/apps/build-apps) guide for the page runtime, the `window.erdo` client, and the read/write model. ### Deploy Page ``` POST /v1/pages ``` | Parameter | Type | Description | | --------------- | --------- | ----------------------------------------------------------------------------------------------------------------------- | | `title` | string | Page title shown in Erdo | | `html` | string | Full document or fragment. With the `react-tailwind` runtime, include a root element and put React code in `js` | | `css` | string | Optional. Stylesheet | | `js` | string | Optional. JavaScript/JSX; `window.erdo` is available | | `runtime` | string | Optional. `react-tailwind` (default) or `none` | | `dataset_slugs` | string\[] | Optional. Datasets the page reads — granted read access (you must be able to view them); unknown slugs are a hard error | | `public` | boolean | Optional. `true` makes the page publicly viewable (view-only). Default `false` | Returns the page `id`, editor `url`, optional `public_url`, and structured `validation` results. A deploy with validation errors still saves — fix them and iterate. ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/pages" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Sales Dashboard", "html": "
", "js": "const {rows} = await erdo.queryDataset(\"acme.sales\", {limit: 1000}); /* render */", "dataset_slugs": ["acme.sales"], "public": false }' ``` ### Update Page ``` PUT /v1/pages/:pageID ``` Provided fields are **merged** — send only `js` to fix a script without resending `html`/`css`. Omit any field to keep its current value. `dataset_slugs` replaces the current grants; `public` toggles visibility. Returns fresh `validation` results. ### Validate Page ``` POST /v1/pages/validate ``` Dry-run validation without deploying: HTML structure, JS/JSX syntax and runtime smoke checks, `window.erdo` usage, and dataset-slug references. Accepts `html` (required), `css`, `js`, `runtime`, and `dataset_slugs`. *** ## Automations (Heartbeats) Heartbeats are recurring agents that analyze your data on a schedule. ### List Heartbeats ``` GET /v1/heartbeats ``` | Parameter | Type | Description | | --------- | ------ | ------------------------ | | `limit` | number | Max results (default 20) | | `offset` | number | Pagination offset | ### Create Heartbeat ``` POST /v1/heartbeats ``` | Parameter | Type | Description | | --------------------- | --------- | --------------------------------- | | `name` | string | Name | | `instructions` | string | What to do on each run | | `interval_minutes` | number | Run frequency (min 5) | | `description` | string | Optional description | | `timezone` | string | Optional (default UTC) | | `active_window_start` | string | Optional. e.g. `09:00` | | `active_window_end` | string | Optional. e.g. `18:00` | | `active_days` | number\[] | Optional. 0=Sun..6=Sat | | `dataset_ids` | string\[] | Optional dataset UUIDs | | `effort` | string | Optional: `low`, `medium`, `high` | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/heartbeats" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Revenue monitor", "instructions": "Check daily revenue for anomalies and alert if down >10% vs last week", "interval_minutes": 60, "dataset_ids": ["dataset-uuid"], "active_window_start": "09:00", "active_window_end": "18:00", "timezone": "America/New_York" }' ``` ### Run Heartbeat ``` POST /v1/heartbeats/:id/run ``` Trigger a heartbeat immediately, outside its schedule. ### Enable or Disable Heartbeat ``` POST /v1/heartbeats/:id/state ``` Pause a misbehaving automation so it stops running, or resume a paused one. | Parameter | Type | Description | | --------- | ------ | ---------------------- | | `state` | string | `active` or `disabled` | ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/heartbeats/HEARTBEAT_ID/state" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"state": "disabled"}' ``` ### List Heartbeat Executions ``` GET /v1/heartbeats/:id/executions ``` | Parameter | Type | Description | | --------- | ------ | ------------------------ | | `limit` | number | Max results (default 10) | *** ## Rendering ### Render Chart ``` POST /v1/render/chart ``` Render a data visualization. Supports bar, line, pie, histogram, and scatter charts. See [MCP docs](/mcp/overview#erdo_render_chart) for full schema. ### Render Table ``` POST /v1/render/table ``` Render a data table. See [MCP docs](/mcp/overview#erdo_render_table) for full schema. # Realtime updates Source: https://docs.erdo.ai/api/realtime Refresh an external app when records, conversations, attention, and approvals change # Realtime updates Erdo's organization WebSocket tells an external app when its canonical data has changed. Events are small invalidation signals rather than copies of datasets or messages: when one arrives, re-read the affected view through `/v1` so current permissions, dataset filters, and response contracts still apply. ## Keep the API key on your server The browser never needs your Erdo API key. Add a same-origin route to your app's server that calls the ticket endpoint with the browser application's exact origin: ```bash theme={null} curl -X POST https://api.erdo.ai/v1/realtime/tickets \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Organization-ID: acme" \ -H "Content-Type: application/json" \ -d '{"origin":"https://app.example.com"}' ``` ```json theme={null} { "ticket": "one-time-ticket", "organization_id": "organization-uuid", "expires_in_seconds": 300 } ``` The active organization comes from normal API authentication, including an optional `X-Organization-ID` header. A caller cannot request a ticket for a different channel or organization. The ticket expires after five minutes, is consumed by the first successful connection, and accepts only the exact origin supplied above. ## Connect and subscribe Return the ticket—not the API key—from your server route to your browser. Convert the API base URL to WebSocket protocol and include the authorized organization in the connection query: ```js theme={null} const bootstrap = await fetch("/api/erdo-realtime", { method: "POST" }).then( (response) => response.json(), ) const url = new URL("https://api.erdo.ai/websocket/connect") url.protocol = "wss:" url.searchParams.set("ticket", bootstrap.ticket) url.searchParams.set("resource_type", "organization") url.searchParams.set("resource_id", bootstrap.organization_id) const socket = new WebSocket(url) socket.addEventListener("open", () => { socket.send(JSON.stringify({ type: "subscribe", channel: { type: "organization", resource_id: bootstrap.organization_id, }, })) }) ``` Wait for the `subscribed` control message before showing a live status. A ticket is one-time, so request a fresh ticket before every reconnect. Use exponential backoff and keep a visibility-aware periodic refresh as recovery for missed events. While the socket is open and the tab is visible, send `{"type":"ping"}` every 30 seconds; Erdo answers with `type: "pong"` and keeps the connection alive. ## Handle events Broadcast messages have a top-level `type`, `channel`, and `timestamp`. `payload` is a JSON-encoded string whose fields depend on the event type: ```js theme={null} socket.addEventListener("message", (message) => { const event = JSON.parse(message.data) if (event.type === "subscribed") return switch (event.type) { case "record_captured": case "thread_activity": case "attention_created": case "attention_updated": case "approval_created": case "approval_decided": case "entity_autolink_result": refreshVisibleData() break default: // Forward compatibility: ignore event types this client does not know. break } }) ``` `record_captured` is emitted only after a successful record-capture pipeline run. It names the pipeline, execution, and written dataset references but never includes the submitted record. Page views and widget telemetry do not emit this event, so normal traffic cannot create an app-wide refresh loop. `thread_activity` likewise contains conversation identity and activity type, not message content. The WebSocket is a prompt to re-read, not a durable event log. Debounce bursts of events, refresh only while the tab is visible, reconnect with a new ticket after a disconnect, and retain a slower periodic refresh so the UI converges even if a producer has no realtime event yet. # Scoped API Keys Source: https://docs.erdo.ai/api/scoped-keys Mint an API key confined to a capability allowlist and specific datasets or workstreams # Scoped API Keys A normal API key acts as *you*: it can read every dataset, drive agents, and mutate resources in any organization you belong to. That is the right shape for an operator credential and the wrong shape for a server that only needs to do one job — a portal backend that queries two datasets and writes rows into one should not hold a credential that can do everything else too. A **scoped key** is an `erdo_api_*` key minted with a **capability allowlist** and, optionally, a **resource-slug allowlist**. It authenticates exactly like a normal key, but it can only call the `/v1` endpoints its capabilities map to, and (where slugs are given) only against the named datasets and workstreams. Everything else returns `403 permission_denied`. ## Minting a scoped key Mint from a session or a normal (unscoped) API key via `POST /v1/tokens` — a scoped key can never mint keys itself: ```bash theme={null} curl -X POST https://api.erdo.ai/v1/tokens \ -H "Authorization: Bearer YOUR_UNSCOPED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "portal-server", "expires_in_days": 90, "capabilities": ["datasets:query", "datasets:write-rows"], "resource_slugs": ["acme.leads", "acme.lead-grades"] }' ``` The raw key is returned **once**. List and revoke scoped keys exactly like normal keys: `GET /v1/tokens` (scoped keys carry `is_scoped`, `capabilities`, and `resource_slugs`) and `DELETE /v1/tokens/{id}`. A scoped key is **pinned to the organization it was minted in**. Its slug allowlist names resources *within that org*, so the key rejects any `X-Organization-ID` header that names a different organization — even one its owner belongs to. ## The capability vocabulary The vocabulary is small and **closed** — minting with any string not listed here is rejected: | Capability | Grants | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `datasets:query` | Slug-addressed dataset reads: `POST /v1/datasets/{slug}/query`, `/query-nl`, `/fetch`, and `GET /v1/datasets/{slug}/filters` | | `datasets:write-rows` | `POST` and `DELETE /v1/datasets/{slug}/rows` | | `agents:run` | `POST /v1/ask`, thread create/send/read, run reads | | `approvals:read` | `GET /v1/approvals` | | `approvals:decide` | `POST /v1/approvals/{id}/decide` | | `integrations:read` | Integration metadata reads (never connect or delete) | | `resources:read` | Read-only resource surfaces: workstreams, experiments, pages, knowledge, KV, event pipelines, heartbeats, emails, artifacts, activity | | `resources:write` | The write side of those resource surfaces | `GET /v1/me` works with any scoped key — it only echoes the caller's own identity and org, and is the "does this key work" probe. ## Fail closed Scoped keys **deny by default**: * An endpoint that is not explicitly mapped to a capability is unreachable — including endpoints added to `/v1` after the key was minted, the `/mcp` endpoint, and the legacy SDK surface. New surface area never silently widens an existing scoped key. * Deliberately unmapped today: org-wide dataset listing and search (`GET /v1/datasets`, `/v1/datasets-search`) and ID-addressed dataset reads — a slug-confined key cannot enumerate or address datasets outside its allowlist. * When `resource_slugs` is non-empty, slug-addressed dataset and workstream calls outside the allowlist are denied even when the capability is granted. ## What no capability can grant Regardless of the capabilities a scoped key was minted with, it can **never**: * mint or manage credentials of any kind (`/v1/tokens`, scoped-token minting, the manager key), * touch managed-organization surfaces (create, revoke, adopt, or seat members), * connect or delete integrations, or create integration connect links. These are hard-denied at the platform, so a leaked scoped key cannot escalate back to the full credential it was minted to replace. # Approvals Source: https://docs.erdo.ai/approvals List and decide approval requests — actions an agent paused on, awaiting a human decision — over MCP, REST, or the CLI # Approvals Some agent actions are sensitive enough to require a human decision before they run — sending an email, writing to a connected integration, and other side-effecting operations. When an agent hits one of these, it creates an **approval request** and the run pauses until a human approves or rejects it. Read-only work never needs approval: queries, reports, fetches, computations, and reads of connected systems proceed without a gate, including APIs that use POST to run a report. External side effects remain gated: native actions carry approval on the action itself, while generated code must classify the effect and name the concrete mutation before it can ask for approval. Every gated action also declares a **risk class** — `spend`, `outbound`, `destructive`, or `bookkeeping` — and an organization in `propose` autonomy can choose which classes still raise a card via the [`autonomy_gates` setting](/autonomy#risk-tiered-approval-gates--propose-the-irreversible). Unset, every gated action raises a card as it always has. Each approval request records what the agent wants to do (`action_key`, `action_display`), the run/thread/job it belongs to, its `status` (`pending`, `approved`, `rejected`, `withdrawn`, or `expired`), and when it was created and decided. When an action belongs to a Workstream/Strategy, the request also carries `workstream_id`. This enables an exact operating view without parsing job names or action text. Approving or rejecting a request resumes the paused agent run: an approval lets the action proceed, a rejection terminates it. The same surface is available over the CLI, MCP, and REST. ## A pending ask waits until it's no longer needed An approval request does **not** age out just because it's been sitting for a while — if you're away for a week, your pending decisions are still there when you get back. A request leaves the pending state only when it is genuinely no longer needed: * **Decided** — you approve or reject it. * **Superseded** — the agent proposes a newer version of the same action on the same subject, which folds onto the existing request (see below) rather than piling up. * **Withdrawn** — the automation that raised it was disabled or deleted, so there's nothing left to run. The request is marked `withdrawn`. * **Expired** — only for the rare action that declares its own semantic deadline (a "today's report" ask is moot tomorrow). Most actions declare none, so most requests never expire. "Publish this page" has no deadline and waits indefinitely. Approving a job's ask **after** the original run has already finished re-runs the automation and applies the approved action — a late "yes" still does the thing you approved, rather than quietly doing nothing. ## Repeated proposals dedupe If an agent proposes the same action on the same subject — say pausing the same ad group — while an earlier request for it is still pending, Erdo does **not** file a second request. It folds the re-proposal into the existing one and bumps an `occurrence_count`, so a repeatedly-attempted action shows up once as "proposed N× since \" rather than as a stack of identical cards. The newest attempt's run is the one your decision resumes. The count and first-seen date are preserved across resolutions, so you can see how persistently an action has been retried. ## The subject an approval acts on Most approvals are about a specific thing — a page about to be published, a record about to be written, an ad group about to be paused. When the action names that thing, the request carries it as a **typed subject**: `subject_resource_type` (for example `artifact`) and `subject_resource_id`. Both are absent when the action has no resolvable subject. The point of the typed subject is that you don't have to parse `action_display` to know what's being changed — you can resolve the resource and show the real thing before you decide. For an approval to publish a page, that means fetching the artifact and rendering or previewing it, so the decision is made against the page itself rather than a description of it. `action_display` remains the human-readable summary; the subject is the machine-readable handle to the object underneath it. ## What the request already tells you A decision needs to be legible in the moment you make it, and the gated action's raw input is the wrong place to learn what it does: that payload is shaped for the system being written to, so a client reading it has to infer business meaning from provider field names — and infers it wrong the day a payload changes. Every request therefore carries a small set of **derived card fields**, computed before the response is sent and identical wherever the approval is shown, so no caller ever has to open `action_input` to work out what approving would do. | Field | What it tells you | | ----------------------- | ---------------------------------------------------------------------------------------------------- | | `action_headline` | The outcome in one line — what changes if you approve, rather than which tool runs. | | `action_items` | The exact actions the decision covers, one entry each. | | `omitted_items` | How many actions were left out of `action_items` when the request covers more than fits (see below). | | `decision_class` | The kind of business decision being made, such as `paid_media.campaign.budget` or `page.publish`. | | `subject_display_name` | The human name of the thing being acted on — a page's title, a campaign name, an email recipient. | | `card_contract_version` | The version of this set of fields, which a renderer keys its expectations on. | Each entry in `action_items` describes one exact action: `what` it does, the `why` its producer recorded for it, its own `decision_class`, and `params_compact` — a flat map of the few parameters worth showing on a card. A request that covers a single action has exactly one entry; a request covering several has one per action, ordered the way the headline describes them, so an approver reading "3 ad groups paused, 1 budget raised" finds the items in that order. `action_items` is bounded at **20** entries. A response that grew with the size of the request would make a list of pending approvals arbitrarily large, and an itemization nobody can read is not a detail view either — so anything past the cap is reported honestly as a count in `omitted_items` ("and 43 more") rather than silently truncated. `params_compact` only ever carries the parameters a gated action publishes as safe to show, never a dump of its input: a card is read by whoever can see the approval, and guessing which of a provider's fields are safe or meaningful is exactly how a card ends up leaking one field and omitting the one that mattered. An action that publishes no such parameters ships an item with none, and its `what` and `why` still carry the decision. `subject_display_name` is **absent rather than guessed**. When the action names no resolvable subject — or a multi-action request's actions disagree about theirs — the field is simply not there, and the right fallback is the typed `subject_resource_type` / `subject_resource_id` above, or nothing at all. A plausible name derived from an id is worse than no name, because it reads as fact. `decision_class` follows the same rule on a request covering several actions: it is present only when every action shares one class. Eight ad-group pauses are genuinely one kind of decision and say so; a page publish and a budget rise that merely arrived together are not, so the request classifies itself as neither rather than borrowing the first action's label. ### What the change is expected to do A request may also carry `expected_effect`: what the agent proposing the change said it would move, written before you answer. It is an object naming the `metric`, the `direction` it should go, the `target` it should reach or the `min_change` that would count as it having worked, the `horizon_days` to judge it over, and optionally a `filter` narrowing it to one campaign, page or segment and a `baseline` note saying what the agent believes the number is today. The field is **absent on most requests, and that is the honest answer** rather than a gap: a permission fix or a bookkeeping correction has no business effect anything could measure, and a metric invented to fill the field would be scored later as if somebody had meant it. Where it is present it is stated as the proposal was made — nothing re-derives it afterwards from what happened. ## Deciding an approval A decision carries a **scope** that controls how broadly the approval applies: | Scope | Effect | | ------------------------ | -------------------------------------------------------------------------- | | `once` (default) | Approve just this one request. | | `always_this_job` | Auto-approve this action for the rest of this job. | | `always_this_workstream` | Create a standing policy for this action on this Workstream/Strategy only. | | `always_org` | Create a standing org-wide policy auto-approving this action. | | `always_user` | Create a standing policy auto-approving this action for the deciding user. | Scope only applies to approvals; a rejection always applies once. For approvals attached to a workstream, Erdo's approval cards default their standing option to `always_this_workstream`; widening it to the organization remains an explicit choice. A standing policy always carries parameter constraints — the bounds on what it auto-approves, such as a specific spreadsheet or recipient. Each request comes with pre-computed constraint bundles (`scope_options`, visible in `erdo approvals list --json`), ordered from most to least specific. When a decision arrives without explicit constraints, the narrowest bundle is used; the decide response reports whether a standing policy was created. ## CLI ```bash theme={null} # List pending requests (omit --status for all) erdo approvals list --status pending erdo approvals list --json # raw JSON erdo approvals list --status pending --workstream brickell-lead-strategy # Only approvals whose gated action acts on one typed subject resource erdo approvals list --subject-type paid_media_campaign --subject-id 1234567890 # Read one request in full before deciding it erdo approvals show erdo approvals show --json # raw JSON # Approve or reject a request erdo approvals decide --approve erdo approvals decide --reject erdo approvals decide --approve --scope always_user erdo approvals decide --approve --scope always_this_workstream # Standing approvals carry parameter constraints that bound what the policy # auto-approves. By default the narrowest of the request's scope options is # used; pick a different one by index, or pass explicit constraints. erdo approvals decide --approve --scope always_org --option 2 erdo approvals decide --approve --scope always_org \ --constraints '{"spreadsheet_id":{"values":["1FftG..."]}}' ``` `erdo approvals list` prints one row per request: a short id, status, action key, display, and creation time — plus a trailing `×N` when the action was re-proposed and deduped. Use `--json` for the full payload, including `occurrence_count` and `first_proposed_at`. `erdo approvals show` prints the request the way a decision is made against it: the headline first, then the status, decision class, subject, and dates, then the reason recorded for the ask, then a numbered list of the actions it covers with each one's reason and shown parameters, and finally the scope options — numbered so the number you read is the one you pass to `erdo approvals decide --option N`. ## MCP tools | Tool | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `erdo_list_approvals` | List approval requests, filterable by status, exact `workstream_slug`, and the typed subject resource the gated action acts on (`subject_resource_type` + `subject_resource_id`). | | `erdo_get_approval` | Read one request in full by `id`: the headline, the itemized actions with the reason each was proposed and the parameters its action shows, the subject it acts on, the reason recorded for the ask, and the scope options a standing decision can use. | | `erdo_decide_approval` | Approve or reject a pending request so the paused run can continue (or be rejected). Takes `id`, `decision`, optional `scope`, and optional `parameter_constraints` for standing scopes (defaults to the request's narrowest scope option). | ## REST | MCP Tool | REST Endpoint | Method | | ---------------------- | -------------------------- | ------ | | `erdo_list_approvals` | `/v1/approvals` | GET | | `erdo_get_approval` | `/v1/approvals/:id` | GET | | `erdo_decide_approval` | `/v1/approvals/:id/decide` | POST | ```bash theme={null} # List pending approvals curl "https://api.erdo.ai/v1/approvals?status=pending" \ -H "Authorization: Bearer YOUR_API_KEY" # Read one request in full curl https://api.erdo.ai/v1/approvals/ \ -H "Authorization: Bearer YOUR_API_KEY" # Approve a request (scope defaults to "once") curl -X POST https://api.erdo.ai/v1/approvals//decide \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"decision": "approved", "scope": "once"}' ``` `GET /v1/approvals/:id` is the drill-in read. It returns one request with everything a list row carries plus the reason recorded for the ask and the scope options a standing decision can use — the list stays sized for scanning, and the detail read is what a decision is made against. An id belonging to another organization comes back `404` rather than `403`, so an id can never be probed for existence. Add `workstream_slug=` to the list query for an exact Strategy/workstream view. Add `subject_resource_type=` and/or `subject_resource_id=` to narrow the list to approvals whose gated action acts on one typed resource — for example, every decided approval that touched one paid-media campaign. The filter matches the subject stamped on the request at creation (each response row carries it as `subject_resource_type` / `subject_resource_id`); approvals whose action declares no typed subject never match a non-empty filter, so filtering only ever narrows what you would otherwise see. # Build Apps on Erdo Source: https://docs.erdo.ai/apps/build-apps Deploy authenticated, data-wired HTML apps to Erdo — read your data directly, write through governed APIs, and go realtime with a few lines of JavaScript. # Build Apps on Erdo Erdo pages are small apps that run in a managed runtime with first-class, permission-aware access to your data. A page can query your datasets, read and write per-page state, react to other viewers in realtime, and submit events into pipelines — all under your existing Erdo RBAC, with no servers to run. You write plain HTML/CSS/JS (React is available by default). Erdo hosts it, injects the `window.erdo` client, wires data access to your grants, validates the build, and gives you a private editor URL plus an optional public share link. **The model in one line:** pages **read directly** (`queryDataset`, `kv.get`, channel subscribe) and **write through governed, RBAC-checked APIs** (`kv.set`, `insertRows`, `submitEvent`). Identity is always the signed-in viewer — attribution and authorization come from your token, never from anything the page sends. ## Quickstart — deploy from Claude Code in under 5 minutes Pages deploy through the [Erdo MCP server](/mcp/overview), so any MCP client (Claude Code, Claude Desktop, Cursor) can ship one. Connect the server first (see [MCP Quick Start](/mcp/overview#quick-start)), then ask your agent to deploy a page — it calls `erdo_deploy_page` for you. ```bash theme={null} claude mcp add erdo \ --transport http \ --url https://api.erdo.ai/mcp \ --header "Authorization: Bearer YOUR_API_KEY" ``` > "Deploy a page titled *Sales Dashboard* that charts my `acme.sales` dataset by month." The agent writes the HTML/JS and calls `erdo_deploy_page` with `dataset_slugs: ["acme.sales"]`. You get back an editor URL, validation results, and (if you asked for `public: true`) a share link. A deploy with validation errors still saves — the agent fixes them with `erdo_update_page` (send just `js` to patch a script) and re-checks until the build is clean. ## The deploy tools These are MCP tools (and matching REST endpoints) — the external front door to the same artifact service, validation, and renderer that Erdo's own agents use. | Tool | What it does | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `erdo_deploy_page` | Deploy a new HTML page/app. Returns its ID, editor URL, optional public URL, and structured validation results. | | `erdo_update_page` | Update a deployed page. Fields are **merged** — send only `js` to fix a script without resending `html`/`css`. | | `erdo_validate_page` | Dry-run validation (HTML structure, JS/JSX syntax + runtime smoke checks, `window.erdo` usage, dataset-slug references) without deploying. | | `erdo_list_artifacts` / `erdo_get_artifact` | Find and inspect pages you've already deployed. | ### `erdo_deploy_page` inputs | Field | Type | Notes | | ------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `title` | string | Page title shown in Erdo. | | `html` | string | Full document or a fragment. With the default runtime, include a root element (e.g. `
`) and put React code in `js`. | | `css` | string? | Optional stylesheet. | | `js` | string? | Optional JavaScript/JSX. `window.erdo` is available; with `react-tailwind`, React 18 + Tailwind + Erdo UI components are loaded. | | `runtime` | string? | `react-tailwind` (default) or `none`. | | `dataset_slugs` | string\[]? | Datasets the page **reads** via `window.erdo.queryDataset`. The page is granted read access to each — **you must be able to view them**. Unknown slugs are a hard error, not a silent drop. | | `writable_dataset_slugs` | string\[]? | Datasets the page may **append to** via `window.erdo.insertRows`. The page is granted write (edit) access to each — **you must hold edit yourself**. List a dataset in both if the page reads *and* writes it. | | `kv_slugs` | string\[]? | Named [KV stores](#per-page-state-kv) (collections) the page **reads** via `erdo.kv.get/list` with `{ kv: "slug" }`. Granted read access on each. The page's own private KV store needs no grant. | | `writable_kv_slugs` | string\[]? | Named KV stores the page may **write** via `erdo.kv.set/delete`. Granted edit access on each — **you must hold edit yourself**. | | `public` | bool? | `true` makes the page publicly viewable (view-only) at its share URL. Default `false`: only you and people the page's thread is shared with can view it. | **Write grants are opt-in.** `erdo.insertRows` and writes to a **named** KV store only work when the page was deployed with the matching `writable_dataset_slugs` / `writable_kv_slugs`; otherwise they return a permission error. Reads (`dataset_slugs`), the page's own private KV store, and `submitEvent` (pipelines) need no write grant. `erdo_update_page` takes the same fields to add or replace grants later (send `[]` to clear one). **Runtimes.** `react-tailwind` (default) preloads React 18, Tailwind, and the Erdo UI SDK (`DatasetChart`, `DatasetTable`, …) so your `js` can render components against query results immediately. `none` ships your HTML/CSS/JS with no runtime scripts injected — use it for fully hand-rolled pages. ## The `window.erdo` client Every page gets `window.erdo`, a Promise-based client that talks to the Erdo platform over a sandboxed bridge. Everything it does runs as **you** (or, on a public page, as the anonymous viewer) under your RBAC. ### Reading data Every dataset is queried as the table **`data`** — the slug in the call selects *which* dataset, and the SQL addresses its rows as `FROM data`, regardless of backend. The dialect follows the storage: file datasets (CSV/Excel) use **DuckDB** SQL, synced integration datasets use **ClickHouse** SQL. Both support `WHERE` / `GROUP BY` / `ORDER BY` / `CAST` / aggregations. ```js theme={null} // Query a dataset you've been granted (returns columns + rows) const { columns, rows, row_count } = await erdo.queryDataset("acme.sales", { query: "SELECT month, revenue FROM data ORDER BY month", limit: 1000, }); // Same query, shaped as objects keyed by column name (usually what you want) const records = await erdo.queryAsObjects("acme.sales", { limit: 100 }); // File datasets (CSV/Excel) with multiple sheets: pass resource_key to pick one. // Pass filters to apply the page's filter-bar state server-side. const sheet = await erdo.queryAsObjects("acme.workbook", { resource_key: "Q1", filters: erdo.getFilters(), }); // Discover and inspect datasets const { datasets } = await erdo.listDatasets({ limit: 50 }); const dataset = await erdo.getDataset("acme.sales"); // schema, columns, resources // Other resources you can dereference by id await erdo.getResource("knowledge_object", id); await erdo.getKnowledgeObject(id); // { object, links, backlinks, related_objects } await erdo.getAsk(id); // a saved shortcut/ask await erdo.runAsk(id); // open it in a new thread const { artifacts } = await erdo.listArtifacts({ type: "html_page", limit: 20 }); ``` ### Rendering results — reach for the UI components first The `react-tailwind` runtime preloads the Erdo UI components as globals, so a table, chart, or KPI should be one of these — not hand-rolled markup. They share the platform's formatting, theming (light/dark), loading and empty states, so a page built from them looks native and stays consistent as the design system evolves. The three high-level components fetch their own data — give them the dataset slug and (optionally) a SQL query, always with `invocationId="iframe"`: ```jsx theme={null} // A table: columns pick, rename and format fields; the row order is the // query's ORDER BY (see note below). // A chart: same self-fetching pattern (line, bar, pie, scatter, heatmap, histogram) // A single KPI value ``` When several charts share one result set, fetch once with `erdo.queryAsObjects` and pass the array to the lower-level components (`BarChart`, `LineChart`, `PieChart`, `ScatterChart`, `HeatmapChart`) — each takes `data`, a `displayConfig` (`{ seriesKey: { label, color } }`), and a `dataConfig` (`{ chartType, xAxis, yAxes, series }`), plus `stacked` for stacked bars. **Sorting is expressed in the query, not the table.** `DatasetTable` renders rows in the order the query returns them and has no clickable-header sorting — to let viewers re-sort, render a small sort control whose state rewrites the `sqlQuery`'s `ORDER BY` (keying the component on the sort choice re-fetches). The same applies to filtering: push it into `WHERE`, or wire the page filter bar through `erdo.getFilters()`. ### Writing data Two governed write paths — pick by **who's writing**: ```js theme={null} // 1. insertRows — direct, RBAC-checked append. Needs a SIGNED-IN viewer and a // page deployed with this dataset in writable_dataset_slugs. await erdo.insertRows("acme.leads", [ { name: "Ada", email: "ada@example.com" }, ], { key_column: "email" }); // → { rows_affected } // 2. submitEvent — the pipeline path. Works for ANONYMOUS public-page visitors // too, so it's the right choice for public lead forms and actions. const res = await erdo.submitEvent(pipelineId, { name, email }, { variant: "signup-form" }); // → { ok, status, body } ``` **`insertRows` requires a signed-in viewer with a write grant** (`writable_dataset_slugs` on deploy). It is *not* served for anonymous visitors — a public page capturing leads from logged-out users must use `submitEvent` into a `dataset.write` pipeline, which carries its own trusted-intermediary identity. Use `insertRows` for internal/authenticated tools; use `submitEvent` for public capture. ### Per-page state (KV) `erdo.kv` (aliased as `erdo.collections` for already-published pages) is per-page key/value state with two partitions: * **`shared`** — one value for the whole page, visible to every viewer. Collaborative state (a counter, a guestbook, a board). * **`viewer`** — private to the signed-in viewer (their draft, their preferences). Requires a signed-in user. ```js theme={null} // Read (direct, viewer-authorized) const { value, found, updated_at } = await erdo.kv.get("theme", { partition: "viewer" }); const { items } = await erdo.kv.list({ prefix: "todo:", partition: "shared" }); // Write (direct, RBAC-checked) await erdo.kv.set("theme", { mode: "dark" }, { partition: "viewer" }); await erdo.kv.delete("todo:42", { partition: "shared" }); // Pass { kv: "slug" } to target a named, org-level store await erdo.kv.set("count", 1, { partition: "shared", collection: "leaderboard" }); ``` **Every write needs a signed-in viewer.** Both `viewer` and `shared` writes require a verified identity — an anonymous visitor to a public page can *read* shared state but cannot write it (until anonymous identity ships). The page's own VIEW access is the gate; for named KV stores an explicit EDIT grant is additionally required (see [Auth & sharing](#auth--sharing)). ### Realtime channels `erdo.channel(topic)` is pub/sub between everyone viewing the page — the basis for multiplayer. ```js theme={null} const chan = erdo.channel("cursors"); // Subscribe; returns an unsubscribe function const off = chan.subscribe((msg) => render(msg)); // Publish to other viewers await chan.publish({ x, y, user: "Ada" }); // later off(); ``` Shared-KV mutations automatically broadcast on the reserved **`kv.change`** topic, so you can make state reactive without wiring your own messages: ```js theme={null} erdo.channel("kv.change").subscribe(({ kv, key }) => { // re-read the shared value that just changed erdo.kv.get(key, { partition: "shared" }).then(applyUpdate); }); ``` ### Live datasets, filters, and theme ```js theme={null} // Live row/event streams (open a subscription, then listen; clean up on unmount) await erdo.subscribeLiveDataset(datasetId); const offLive = erdo.onLiveEvent((evt) => append(evt)); // { datasetId, payload } // later: offLive(); await erdo.unsubscribeLiveDataset(datasetId); const filters = erdo.getFilters(); // current filter-bar state const offFilter = erdo.onFilterChange((f) => refetch(f)); // Scheduled/agent dataset refreshes — re-query when one lands erdo.getDatasetUpdateVersion(); // monotonic version token const offData = erdo.onDatasetChange((e) => refetch()); // { version, datasetIds, updatedAt } erdo.getTheme(); // 'light' | 'dark' const offTheme = erdo.onThemeChange((t) => setTheme(t)); ``` ## KV vs. datasets — which store? Both store structured values. The decision rule is about **who reads it**: **If anything other than this page reads the value, it's a dataset. If only the page reads it, it's KV.** * **Dataset** — leads from a form, events, anything an agent will analyze, anything another page or report consumes, anything that needs schema/queries/joins. Write with `insertRows` or a `dataset.write` pipeline. * **KV (collection)** — page-private memory: UI state, a draft, a shared scratchpad, a small leaderboard *that only this page renders*. Capped and key/value only. When in doubt, prefer a dataset — KV stores are deliberately small and page-scoped. ## A reactive multiplayer example A shared counter every viewer sees update live, built from two primitives — shared KV + the `kv.change` channel: ```js theme={null} async function render() { const { value } = await erdo.kv.get("count", { partition: "shared" }); document.getElementById("count").textContent = value ?? 0; } document.getElementById("inc").onclick = async () => { const { value } = await erdo.kv.get("count", { partition: "shared" }); await erdo.kv.set("count", (value ?? 0) + 1, { partition: "shared" }); }; // Any viewer's write re-renders for everyone erdo.channel("kv.change").subscribe(({ key }) => { if (key === "count") render(); }); render(); ``` ## Auth & sharing * **Private by default.** A new page is visible only to you and anyone the page's thread is shared with. Set `public: true` (on deploy or update) for a **view-only** public link at `/p/:id`. * **Identity is the signed-in viewer.** `window.erdo` calls run as the authenticated user; the page can never impersonate someone — identity comes from your token, server-side. * **Reads are viewer-authorized.** A page can only read datasets you granted it (`dataset_slugs`), and only viewers who can see the page can read its data and shared state. On a public page, anonymous visitors get view/read access to public content and shared state. * **Writes are RBAC-checked.** Writing the page's own private KV store requires VIEW on the page plus a signed-in identity. Writing a **named** (cross-page) KV store or appending to a dataset additionally requires an explicit EDIT grant the page holds — declared at deploy via `writable_kv_slugs` / `writable_dataset_slugs`. Org membership alone is not enough. * **One permission model.** Pages reuse Erdo's standard RBAC (`view` / `edit`) — no separate app-level permission system. ## Limits | Limit | Value | | -------------------------- | ------------------------------------------------------------------------- | | Collection value size | 64 KB per item | | Collection keys | 1,000 per partition | | Event body size | 1 MB per submission | | Viewer write rate (events) | 300 / minute sustained, burst 60 | | Page content | HTML + CSS + JS (single page; multi-file bundles are not supported in v1) | Pages run today's artifact shape (HTML/CSS/JS) in the Erdo page runtime — they are not arbitrary built bundles (Vite/webpack output). The runtime injects React/Tailwind/the bridge, validates the result, and wires data access. This keeps every page governed by the same validation and RBAC. ## Next steps Connect your MCP client and see the full tool reference, including the page deploy tools. Deploy and manage pages over HTTP for CI and scripts. # Your attention feed Source: https://docs.erdo.ai/attention One feed carries everything that wants your attention — narrative digests, quick judgment calls, blocked-decision escalations, and judge re-screens — plus a way to steer work mid-run. # Your attention feed As Erdo runs more work on your behalf — building pages, running experiments, watching your data — the question stops being "what are the agents doing?" and becomes "what actually needs me?". Nine times out of ten the answer is nothing, and the interface's job is to make that legible without you reading a log. So everything that wants your attention queues into **one** place — the **Activity** feed — rather than reaching you from several directions at once. There is no separate approvals inbox here, no decisions panel there, no "pick A or B" somewhere else: one stream, ranked by how much a decision hangs on it, with a single budget on how often it is allowed to interrupt. Items come in three shapes, because they do three genuinely different jobs. ## Digests — the narrative channel A **digest** is editorial and asks nothing of you. "Built ten landing pages across five personas; two look unusually strong; nothing needs you." It exists so you can get an innate feel for whether the work is healthy — the daily-standup read, not an event log — and it projects rather than merely reports: what the work is doing, why, and what it will do next. Digests are marked read as soon as they've been on screen, so the feed reflects what you've actually seen. ## Choices — buying your judgment A **choice** is Erdo asking for your *judgment*, not your permission. Where a human comparison is worth more than a machine's — early rounds, new territory, taste-heavy calls — the feed asks for it in the cheapest possible form: pick your top five of these ten, A-or-B five times, rank these three ideas. You answer in seconds by tapping options. Two things make a choice more than a convenient survey. First, every answer is recorded as a labelled comparison — you are the most expensive and most trusted judge Erdo has, so your verdict both steers the live decision *and* scores the cheaper automatic judges against it, sharpening them over time. When a choice is tied to a running [experiment](/experiments), picking an option marks that option's variant as beating the ones you didn't pick, and those comparisons flow into the experiment's calibration record. Second, choices are rationed by the same interrupt budget as everything else, so Erdo has to spend its questions where your judgment actually moves the outcome — it can't nag. ## Seeing the work, not a description of it A choice between landing pages is only as good as what you can see when you decide, so the feed carries the work itself rather than a summary of it. When a choice's options map to pages Erdo has built, each option renders as a **live preview** — the real page running its real code, motion and interactivity included, not a stale screenshot — laid out side by side as a comparison grid on desktop and a swipeable carousel on your phone. You pick by looking, and tapping a preview opens the full page in a new tab. Because the preview is the actual page, comparing rendered work also produces sharper labels for the automatic judges than comparing descriptions ever could. The previews reference the same pages that Erdo publishes and experiments with — the feed adds no new way to run code and no new place for it to reach. A page shown here is rendered exactly as it is, without the traffic-splitting that a live visitor would see, so the card always shows the specific variant you're weighing. For a candidate that isn't a published page yet, Erdo can attach a small self-contained snippet, rendered in the same locked-down sandbox everything else on Erdo runs in. Previews load only as they scroll into view, so a long feed of live pages stays light. Digests can carry evidence the same way: when a number tells the story — leads by variant this week, cost per booked meeting — the digest attaches a **chart or table** drawn with the same components you see elsewhere in Erdo, rather than spelling the figures out in prose. It's shown only when seeing the shape changes what you'd conclude, so the narrative channel stays a narrative and doesn't turn into a dashboard. ## Escalations — a blocked question, already half-answered An **escalation** is a question the work is blocked on — but never a bare question. It arrives with Erdo's **proposed answer**, a **safe default**, and an **expiry**. The workstream keeps doing what it still can while it waits; you either accept the proposal in one click or give a different answer. And if you don't respond by the expiry, the safe default is applied automatically and the work continues — a stalled decision never silently halts a workstream. This is guidance, not a hand-off: Erdo has done the thinking and needs only your confirmation or correction. ## Judge re-screens — a standard moved under live work When a [judge](/judges) is updated — its rubric edited, or the shared principles it reviews against revised — every *future* page automatically gets built to the new standard. But pages already serving live traffic in a running experiment were screened under the old one, and a **judge re-screen** item is how the gap surfaces: Erdo re-runs the changed judge over the live variants, and if a judge with a trusted track record now finds blocking issues, one item per affected experiment appears in the feed naming the variants and the top findings. Nothing is changed automatically — the variants keep serving, because a moving standard is not a mandate to churn live pages. The item proposes the response instead: an iterate bet on that experiment, so any fix goes through the same build-and-screen path as every other change. Judges still earning their calibration don't raise these items at all; their re-screen verdicts land quietly in the calibration ledger. ## Severity and the interrupt budget Every item carries a severity, and severity governs one thing: whether it may interrupt you. * **FYI** and **Needs attention** wait quietly in the feed until you look. * **Urgent** is the only level allowed to break through — and the number of open urgent items is capped per organization. When the budget is full, a new urgent item is automatically downgraded to *Needs attention* rather than piling onto an alarm you're already ignoring. The cap is deliberate. Interfaces that run one person over many processes — flight decks, control rooms, on-call rotations — all converge on the same rule: an alert that fires too often stops being an alert. Capping urgency on the feed as a whole keeps the loudest channel meaningful. ## Held items — deferral you can see Severity does more than decide whether something interrupts you; it decides *when* it reaches the top of the feed. Only **urgent** items land in **Needs you** the moment they're raised. A **Needs attention** or **FYI** choice or escalation is still something you can act on, but it isn't worth breaking your attention for right now — so instead of surfacing immediately it is **held**, and the feed shows a single collapsed row beneath the urgent band: *"3 items waiting."* The same control-room lineage that caps urgency is behind this — a mistimed non-critical alert costs more in lost focus than the delay costs in latency, so the disciplined move is to queue it to a natural breakpoint and let you choose when to look. The count is the point. Deferral must never read as silence, so the number of waiting items is always visible even while their detail is folded away. Two things surface a held item in full: you **expand the row** whenever you want to act, or a **digest arrives for its workstream** — held items ride along with the next narrative update for the work they belong to, so they reach you at the moment you're already reading about that workstream rather than as a standalone interruption. Approvals are never held: an approval is a block you deliberately put yourself in front of, so it always surfaces at once. ## When one failure floods the feed A single root cause can trip many workstreams at once — one broken integration, one bad deploy, one upstream outage — and naively the feed would light up with a dozen urgent items that are really one problem wearing many hats. Control-room history has a name for this failure: the alarm avalanche, where a hundred annunciators fire in seconds and the one fault behind them becomes *harder* to see, not easier. So when the number of open urgent items in a short window crosses a threshold, the feed switches to **overview-first**. The individual alarms collapse behind **clusters** — one row per workstream and kind, *"5 escalations · Lead engine"* — under a synthesized headline that names the shape of the storm: *"12 urgent items across 4 workstreams — likely common cause."* You see the pattern before any single item, expand a cluster only when you want the detail, and the quiet board above the feed is already showing you which workstreams are involved. The clustering is plain grouping, computed the same way every time — there is no guesswork on the read path, so the same storm always renders the same way. ## What surfaces first — ranked by decision, not by clock A feed sorted newest-first buries the one escalation that needs an answer under a wall of completed runs, so the Activity feed is ordered by **decision-value** instead. Anything you can act on right now — an open choice, an escalation waiting on you, a pending approval — rises to the top under a **Needs you** heading, ahead of everything that is merely news. Within that band the order follows urgency: a higher-severity item outranks a lower one, and among items of equal severity the one whose safe default fires soonest comes first — an escalation auto-resolving in two hours sits above one that has until tomorrow, because the sooner it resolves itself the sooner the choice leaves your hands. Below **Needs you** come the digests you haven't read yet, and below those the rest of the timeline in the usual reverse-chronological order. Nothing is hidden; the ranking only decides what you see first. (The interrupt budget already caps how much can pile up, so ordering never has to compensate for volume.) ## The quiet board — state at a glance The feed reports *change*; a small always-on **board** above it reports *state*. It carries one cell per active workstream, and it follows the control-room principle that darkness means normal: when every workstream is healthy and inside its budget, the board collapses to a single muted "all quiet" line with nothing to read, so "is everything OK?" is answerable at a glance without parsing anything. Only the exceptions light up — a workstream that is over its spend envelope, or one carrying an open urgent item — and only those show detail. Each cell links straight to its workstream, so going from "something needs me" to the place you can act on it is one click. ## Following — scoping the feed to what you own The feed works because every item is about *your* money, *your* leads, *your* workstreams — ownership already did the filtering that engagement-ranking never solved for social media. **Following** narrows that one more turn: when you follow a workstream, its digests, choices, and narrative events populate your feed's default view, and the workstreams you don't follow fade into the background. You follow a workstream from its detail page or from its cell on the quiet board, and Erdo follows for you the ones you clearly care about — the workstream you just created, and any you drop a steering note into. An **All** toggle switches back to the whole organization whenever you want the wider view; if you follow nothing, you see everything, so the scoping only ever narrows a feed you'd otherwise have to narrow by hand. One channel is never scoped away: **urgent** items always break through, whether or not you follow the workstream that raised them, and org-level alerts that belong to no single workstream always show. Following quiets the ambient, narrative surface — never the alarm. A crisis on a workstream you weren't watching still reaches you. ## Steering notes — reaching in mid-run The feed is mostly Erdo talking to you; **steering notes** are you talking back without stopping anything. On a [Workstream's](/workstreams) page you can drop a note the moment an idea occurs — a hunch, a constraint, a correction ("the client hates countdown timers"). The note lands in the workstream's event log, and the agents pick it up on their next pass and treat it as new evidence: updating what they're building, dropping ideas the note rules out, spawning ones it suggests. Because Erdo's loops always reconcile against a consistent, written state rather than firing one-shot event chains, steering needs no special ceremony — a note from you is just evidence that happened to arrive from a human instead of from the data. Steering costs you seconds; carrying it out costs you nothing. ## Ask about any item — introspection in one click Every item in the feed carries an **Ask** button. Press it and Erdo opens the owning [Workstream's](/workstreams) thread — where that workstream's full context is already loaded — with a reference to the item pre-filled in the message box ("About attention item …"). You finish the sentence and send. Nothing is sent on your behalf; the prefill is a starting point, not an action. This is deliberate: the deep-explainability layer is chat, which you already have, so "why did the critic block this?" or "what would change your mind about page B?" is one click away rather than a database query. An item with no workstream opens a fresh thread instead. Feed items also carry **actor chips** — the workstream that produced the item, and, where a choice is tied to one, the [experiment](/experiments) it scores — so you can click straight through to the thing itself. The surface stays simple no matter how involved the engine underneath gets; easy introspection is what keeps that simplicity honest rather than opaque. ## Programmatic access The feed is also readable and answerable over **MCP**, the **REST API**, and the **CLI** — the same items you see in **Activity**, org-scoped and RBAC'd. This is how a headless caller reviews what needs a human and responds without opening the app. Responding takes one of three actions. **Answer** a choice or escalation with your pick; when the choice is tied to a running [experiment](/experiments), your answer is recorded as a comparison attributed to `human:` — the option you chose beats the ones you didn't, and those comparisons score the cheaper [judges](/judges) against you. **Acknowledge** marks an item read without deciding it, and **dismiss** clears it from the feed. ```bash theme={null} # read the feed erdo attention list --open # only items still needing you erdo attention list --status urgent needs_attention erdo attention list --engine-actions # only the engine's own autonomous actions erdo attention list --item # one item, by slug or id # respond erdo attention respond --answer '{"choice":"b"}' erdo attention respond --ack erdo attention respond --dismiss ``` ### MCP tools | Tool | What it does | | ----------------------------- | --------------------------------------------------------------------------------------------------------- | | `erdo_list_attention_items` | List feed items (filter by status, engine-actions only, or `item_id` for a single item). | | `erdo_respond_attention_item` | Answer a choice/escalation, acknowledge, or dismiss an item. | | `erdo_list_activity_feed` | Read the ranked attention feed, with optional catalog and automation history, plus urgent cause-clusters. | ### REST Base URL `https://api.erdo.ai`. `Authorization: Bearer ` + `X-Organization-ID`. | Method | Path | | ------ | --------------------------- | | `GET` | `/v1/attention` | | `POST` | `/v1/attention/:id/respond` | `GET /v1/attention` takes optional `statuses`, `engine_actions_only`, `item_id`, `limit`, and `offset` query parameters. `POST /v1/attention/:id/respond` takes `action` (`answer`, `acknowledge`, or `dismiss`) and, for `answer`, the `answer` body. `item_id` is how you read one item: pass its slug or its id and the list comes back narrowed to that single row. It is a filter rather than a separate by-id endpoint deliberately — one place decides which items you may see, so an item that belongs to another organization is simply not in the answer, exactly as it isn't in the unfiltered list. Omit it and nothing is narrowed. ## The whole feed over the API `GET /v1/attention` returns attention items alone. But **Activity** is more than attention items — it can merge attention items, [approvals](/approvals), workstream narrative events, catalog updates, and automation history into one ranked stream. The default is deliberately narrower: the things a person may need to understand or decide, not a log of every successful background operation. `GET /v1/activity/feed` exposes it, so a headless caller reads the same feed a person sees in the app — already ranked, already clustered — instead of fetching each source separately and rebuilding the ordering itself. The default view matches the app's: **attention items, approvals, and workstream events**. Individual job executions, heartbeat executions, and catalog batches are operational history, so they do not crowd the attention feed unless you ask for them. Repeated automation failures still surface as one deduplicated escalation after Erdo's repair attempts; hiding raw executions does not hide sustained breakage. In the app, shared threads, upcoming schedules, and automation suggestions are also hidden by default; select **Shared threads**, **Jobs**, or **Heartbeats** in the type filter to see them. Pass `categories` — a comma-separated subset of `attention`, `approval`, `workstream`, `catalog`, `job`, `heartbeat` — to change the mix; naming `catalog`, `job`, or `heartbeat` opts that history back in. The attention and workstream sources appear only when the engine is enabled for your organization. The response carries the same ordering described above under **What surfaces first**: items you can act on right now lead, sorted by severity and then by which safe default fires soonest, followed by the digests you haven't read, then the rest of the timeline newest-first. Each item carries `needs_action` so you can render a "Needs you" band without re-deriving it, and its `severity`, and — for attention items — a `slug` you respond to. When a storm trips [overview-first mode](#when-one-failure-floods-the-feed), the individual urgent items are marked `clustered` and the response's `urgent_clusters` carry the grouped rows — one root cause behind N alarms — each with a `summary`, a `count`, and the member `item_ids`, alongside a one-line `flood_summary`. The feed endpoint is **read-only**. To act on an item you respond exactly as you would to an attention item or an approval on its own: `POST /v1/attention/:id/respond` to answer, acknowledge, or dismiss a choice or escalation, and `POST /v1/approvals/:id/decide` to approve or reject an approval. `scope` accepts `following` or `all` (default `all`); because an API token is org-scoped and carries no per-user follow set, `following` behaves as `all` for a token and is meaningful only for a user-scoped credential. For one [Strategy](/strategies) or workstream, pass `workstream_slug`. This is an exact operating view: it includes only that workstream's attention, approvals, and narrative events. Urgent and standalone items from elsewhere do not break through, and unscopable catalog/job history is omitted. ```bash theme={null} erdo activity # the ranked feed: needs-you first, then clusters, then recent erdo activity --categories attention,approval,workstream,catalog,job,heartbeat # include all operational history erdo activity --limit 50 --json # raw response for a script erdo activity --workstream brickell-lead-strategy ``` | Method | Path | | ------ | ------------------- | | `GET` | `/v1/activity/feed` | `GET /v1/activity/feed` takes optional `limit` (default 20, max 100), `offset`, `categories`, `scope`, and exact `workstream_slug` query parameters. Its MCP mirror is `erdo_list_activity_feed`. # Automations Source: https://docs.erdo.ai/automations Put agents to work on a schedule or a trigger — so the report, the analysis, or the alert happens without you. Agents don't only work while you watch. Under **Activity** you can set up **automations**: an agent that runs on its own and does the work, then leaves the result (or a notification) for you. ## What you can automate * **Run an agent** — invoke an agent with a set task, optionally with specific [datasets](/data) and [skills](/knowledge) attached. * **Start a conversation** — create a fresh [conversation](/concepts#conversations) and message an agent, so the work shows up as a conversation you can pick up. * **Refresh data** — keep a connected [dataset](/data) up to date on a schedule. ## Agent or script An automation runs one of two ways, and Erdo picks the cheaper one that does the job: * **Agent** — an AI runs each tick, following your instructions. Use it for work that needs judgement every time: interpreting an anomaly, weighing several metrics, writing a digest someone reads. * **Script** — a deterministic check runs each tick with no AI at all. Use it for mechanical work: comparing a number to a threshold, noticing new rows, emailing a templated alert. A script costs nothing to run, so it can fire the moment data lands rather than waking an AI on a timer to look. Both follow the same schedule, the same approval rules, and leave the same history. When you ask for "email me when a new lead comes in", Erdo builds the script kind — it's the same alert an agent would send, without the per-run cost. ## Triggers * **Schedule** — run on a recurring cadence (e.g. every morning, every Monday). * **Webhook** — run when an external system POSTs to the automation's URL; the payload is handed to the agent. ## Refreshes pause themselves when nobody's looking A scheduled data refresh exists to keep something you look at fresh — a dashboard, a published page, the dataset itself. When two weeks pass without anyone viewing what a refresh feeds, Erdo pauses its schedule rather than keep spending runs on data nobody reads. The automation shows as **Auto-paused** under Activity, the workspace owner gets a one-time email, and nothing is deleted: the dataset, its history, and the automation's configuration all stay exactly as they were. Resuming is automatic the moment the output matters again. Open the dashboard it feeds — or a real visitor lands on the published page — and the schedule picks back up, with the stale data refreshed right away. Turning the automation back on from Activity, or running it manually, resumes it too. Only scheduled data refreshes pause this way. Automations that *act* — sending alerts, archiving email, filing reports — are never paused for lack of views, because their work isn't something you watch. ## Notification emails When an automation emails you — a new lead landed, a threshold was crossed, a weekly summary is ready — Erdo composes the message rather than the automation writing raw markup. You get the same styling as every other Erdo email: a heading, the facts as label/value rows, a button through to the page or dashboard the alert is about, and addresses and links that are clickable, so you can reply to a lead straight from the notification. Every message also carries a plain-text version, so it stays readable in a client that blocks HTML and in a forwarded copy. This holds however the alert is built. Erdo will often set an alert up as a small deterministic script — it fires the moment the data lands and costs nothing to run, unlike an agent waking on a timer to check — and those scripted alerts are styled exactly like the ones an agent sends. When the same kind of email goes out from several places — one lead alert across a handful of campaigns, say — Erdo can save it as a reusable **template** in your [Knowledge](/knowledge): the layout is written once, with placeholders for the details, and each send fills in the data. Editing the template updates every automation that uses it, and the values are still escaped and given a plain-text version automatically, so a template is as safe as a one-off message. ## Editing an automation Change an automation in place without recreating it — rename it, move its schedule, pause it, or replace what it does (an agent automation's instructions, a scripted one's body). Its history and its place in your workspace carry over. From the terminal: ```bash theme={null} erdo automations update --interval 30 erdo automations update --script-file alert.js ``` The same edit is available over the API and MCP (`erdo_update_heartbeat`), and in the app. ## Keep an eye on things Everything an automation does is visible under **Activity**: * **Runs** — the execution history, with status, timing, and any errors. * **Feed** — a live log of what's happening across your workspace: agent runs, data refreshes, approvals, and new [knowledge](/knowledge). ## You're still in control Automated runs follow the same rules as interactive ones: consequential actions still go through [approval](/concepts#review-and-approvals), and new knowledge still lands in **Review**. An automation can do the work unattended without being able to act outside the bounds you've set. Unattended runs consume approvals rather than asking for them. Nobody is watching a 6am run, so it never raises a card — a scheduled automation that raised one per tick would fill your queue with asks about work that had already stopped waiting. Instead it looks for a **standing approval** covering what it is about to do, and fails the run with a message naming the action when it finds none. Grant one by running the action once in a conversation and choosing *always allow*, or by creating a policy against the automation directly. Sending email is one of these actions, including from a script's own `actions.invoke('erdo', 'send_email', …)`. If you want mail sent on arrival rather than on a schedule, prefer an [arrival notification](/dataset-notifications): its recipient list is the authorisation, so there is nothing separate to grant and nothing that can quietly widen. # Engine autonomy Source: https://docs.erdo.ai/autonomy One dial — autopilot, propose, or strict — decides how much the growth engine may change live, customer-facing state on its own clock, with a single ledger of everything it has done. # Engine autonomy As the engine runs more of your growth work — shifting traffic between page variants, settling which variant ships, re-grounding your personas in what your real audience does — the question becomes how much of that it should do on its own, and how much it should run past you first. Different clients want different answers, and the same client wants different answers for different work: a brand campaign you watch closely, a long tail of pages you're happy to let run. So the answer is one dial, not a scatter of toggles. ## The three modes Autonomy is a single setting — **`autonomy_mode`** — with three values, set on your organization and overridable per workstream. **Autopilot.** The engine acts immediately and drops a note in your [Activity feed](/attention) telling you what it did. No waiting. Use it for work you trust the engine to run unattended. **Propose** *(the default)*. The engine does the analysis, proposes the change, and gives you a **24-hour window** to veto it. If you say nothing, the change applies when the window closes — the engine keeps its cadence, and you keep a veto. This is the mode that matches "I want both": the work doesn't stall waiting on you, but nothing customer-facing changes without you having had the chance to stop it. **Strict.** The engine proposes and then waits. The change applies **only** if you explicitly approve it; an unanswered proposal lapses to *no*, and the next engine cycle re-proposes from fresh data rather than acting on a stale plan. Use it when every customer-facing change needs a human yes. The **effective mode** for any given action is the workstream's override if it has one, otherwise your organization default, otherwise `propose`. So you can run one workstream strict and another autopilot at the same time without moving the org-wide setting. ## What the dial gates The dial governs exactly the points where the engine changes **live, customer-facing state**: * Applying a traffic-allocation step to a running experiment. * Executing a settled ship-or-stop decision. * Adding a new variant into live traffic. * An engine-initiated edit to a published page. * Re-grounding a persona in place from measured behavior. Everything else — reading your data, forecasting, running the panel, writing predictions and observations — is never gated. Forecasting is free; acting is governed. And the two approval systems stay separate: an [agent-tool approval](/approvals) you grant mid-conversation is a different thing from the engine acting on its own clock, and an action approved through either channel is never re-gated by the other. One human yes is one human yes. ## Risk-tiered approval gates — propose the irreversible Agent-tool approvals have their own dial: **`autonomy_gates`**. By default, `propose` gates every approval-gated agent action the same way — a budget change and the agent updating its own experiment record both raise a card, and the card that is actually about money arrives buried among bookkeeping ones. If that queue teaches you to rubber-stamp, the review the mode exists for is gone. `autonomy_gates` lets you say which **risk classes** of action still need a card when your autonomy is `propose`. Every approval-gated action declares one of four classes: | Class | What it covers | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spend` | Ad-account writes: campaigns, budgets, bids, keywords, conversion uploads — anything that moves money or changes how it's spent. | | `outbound` | Anything leaving the platform or changing what the world sees: emails, SMS, phone calls, CRM and commerce writes, publishing or editing live public pages. | | `destructive` | Deletes and other irreversible state. | | `bookkeeping` | The agent's own records: experiment-state updates, metadata writes, dataset upserts of its own rows. | Unset (the default for every organization), nothing changes: every gated action raises a card, exactly as before. Set it — say `["spend", "outbound", "destructive"]` — and in `propose` mode only those classes raise cards; the rest execute directly, still recorded on the run's activity. That makes `propose` mean *propose the irreversible*: a no-spend build phase runs to completion on its own, and the one card in your queue is the one about money. Three things never change with this setting: **autopilot and strict are untouched** (it narrows what propose asks about, never what other modes allow); an agent's **explicit `request_approval`** always reaches a human; and a standing **"always require" policy** on an action outranks the tier setting. An action nobody has classified fails closed and always raises a card. Read and set it beside the autonomy mode: the `erdo_get_autonomy_gates` / `erdo_set_autonomy_gates` MCP tools, or REST: ```bash theme={null} curl "https://api.erdo.ai/v1/autonomy-gates" -H "Authorization: Bearer YOUR_API_KEY" curl -X PUT https://api.erdo.ai/v1/autonomy-gates \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"gates": ["spend", "outbound", "destructive"]}' # Restore the legacy gate-everything behavior curl -X PUT https://api.erdo.ai/v1/autonomy-gates \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"gates": null}' ``` ## Answering a proposal Proposals arrive as **escalations** in your [Activity feed](/attention) — the same place every other attention item lives, so there's no separate approvals inbox. Each one is written to be judged without opening a dashboard: what the engine wants to do, why, the numbers, and exactly what happens when the window closes. Approve it to act now, veto it to cancel, or ignore it — under `propose` that means yes at the deadline, under `strict` it means no. ## The ledger — what the engine has done Every outcome is recorded, so "what has the engine actually done?" is always answerable. Approvals, vetoes, lapses, and autopilot actions all write an attributed entry noting **which channel authorized it** — autopilot, a human's yes, or a lapsed window — and, for a human yes, who. The **"Engine actions"** filter preset on the Activity feed narrows to exactly these, so you can review the engine's decisions as one stream rather than reconstructing them. ## Changing the mode Set your organization default in settings, or from the CLI: ```bash theme={null} erdo org autonomy # show the current mode erdo org autonomy propose # set it (autopilot | propose | strict) ``` Set a per-workstream override through the workstream — via the app, the `erdo_update_work_state` tool (`autonomy_mode`), or the REST API. Pass `inherit` to clear an override and fall back to the org default. # Web Browsing Source: https://docs.erdo.ai/browser Have an Erdo agent use a real web browser — log in, navigate, fill forms, extract data, download files, and capture screenshots — for the things that only happen in a browser. # Web Browsing Some work doesn't have an API — it lives behind a login, in a portal, on a page with a download button. Erdo agents can drive a **real web browser** to do that work: open a site, sign in, click through a flow, read what's on the page, download a file, or take a screenshot — then bring the results back into your thread. This runs a full browser, not a simple page fetch. It's for **interactive** work — logging in, multi-step flows, clicking, downloading. For querying your connected tools and data, agents use [connectors](/data) and SQL/Python, which are faster and cheaper. ## What you can do * **Get something from behind a login** — "Log into our ad portal and download this month's spend report." * **Do a multi-step task on a site** — "Go to this supplier's site, find the SKU, and tell me the lead time and price." * **Pull data off a page that has no API** — "Read the pricing table on this page and turn it into a dataset." * **Capture a page** — "Take a full-page screenshot of this dashboard." The agent narrates what it's doing, and you can watch the live browser as it works. ## What comes back Whatever the agent produces in the browser comes back as part of the result: * **Files** — anything the agent downloads or exports (a CSV, a PDF, a report) is saved to your Erdo storage and returned as a **download link**, not a throwaway URL. * **Screenshots** — images the agent captures are saved and returned the same way. * **Extracted content** — text, tables, and observations the agent read off the page, ready to use in the rest of the thread (e.g. turned into a dataset). Because files are re-hosted into Erdo, the links keep working after the task finishes — you can open them later or feed a downloaded file straight into a dataset. ## Signing in safely When a task needs to log in, the agent uses **your saved credentials** for that site: * Credentials are stored encrypted and are **scoped to the specific site**. * The agent's reasoning **never sees the actual values** — they're injected straight into the login form by the browser, not read by the model. * Like connectors, credentials are **per user** — the agent acts as you, with your access, and only on the sites you've set up. If a login needs a one-time email code, an agent can read it from an [inbox](/inboxes) you've given it — so a verification step doesn't dead-end the task. ## How it fits with the rest of Erdo * Prefer a [connector](/data) when one exists — connected tools sync into queryable [datasets](/data#upload-a-dataset), which is faster and more reliable than driving a browser. * Use web browsing for the gaps: sites with no API, one-off portal tasks, and anything that genuinely needs a person-like browser session. * A file a task downloads can become a dataset, and a page it reads can become [knowledge](/knowledge) — the browser is a way *in*, and the rest of Erdo takes it from there. Web browsing is an Erdo capability. Under the hood it uses a managed browser provider, but that is an implementation detail we can swap — you connect, authorize, and retrieve everything through Erdo, with your own permissions. # CLI Source: https://docs.erdo.ai/cli Drive Erdo from the terminal or CI — agents, pages, evals, datasets, and more # CLI `@erdoai/cli` wraps the same `/v1` API the [MCP server](/mcp/overview) exposes, so anything an AI assistant can do over MCP you can do (and script) from a shell. ## Install Requires Node.js 18 or later. ```bash theme={null} npm install -g @erdoai/cli # then run `erdo` ``` Or run it without installing: ```bash theme={null} npx @erdoai/cli --help ``` Verify and sign in: ```bash theme={null} erdo --version erdo login # browser sign-in (multi-account, like gh) erdo whoami # you@example.com — org: Acme Inc (acme) ``` ## Update ```bash theme={null} erdo update # checks npm and installs the latest if newer ``` Equivalent to `npm install -g @erdoai/cli@latest`. When a newer version is published, commands print one line telling you so: ``` erdo v0.44.0 is out of date (v0.45.0 is published) — run `erdo update`. ``` It goes to **stderr**, never stdout, so a script or agent parsing the JSON on stdout is unaffected. The published version is remembered on disk and looked up about once a day in the background, so no command waits on the network and an unreachable registry says nothing at all. Set `ERDO_NO_UPDATE_NOTIFIER=1` to silence it. The notice matters more than it looks: an old build's `--help` is accurate about itself and wrong about the product, so a command added since your install simply isn't listed — and nothing else tells you it exists. ## Auth & orgs Accounts are stored at `~/.config/erdo/config.json`, one active at a time. ```bash theme={null} erdo login # browser OAuth; mints + stores a token erdo login --key erdo_api_... # headless/CI: paste an API key instead # over ssh: press c to copy the login URL to # your local clipboard, authorize in your local # browser, paste the localhost callback URL back erdo auth status # list accounts (* = active) erdo auth switch [email] # switch active account (auto-toggles with two) erdo logout [email] erdo org list # your orgs (* = active) erdo org use [id|slug] # set the active org for this account erdo --org acme # one-off override for a single command ``` Env overrides for CI: `ERDO_API_KEY`, `ERDO_ORG`, `ERDO_API_URL`, `ERDO_ACCOUNT`. ### Pin the org in automation The active org set by `erdo org use` lives in machine-global config, so it is shared across every concurrent session — another shell (or another job) running `org use` switches the org for all of them. A command that only reads is harmless, but a mutation that lands in the wrong org is not, so any command that writes prints the org it is about to act in on stderr before it fires: ``` org: acme (active org — pass --org to pin) org: acme (pinned) # when --org / ERDO_ORG was set ``` For scripts and CI, don't rely on the shared active org — pin it explicitly with `erdo --org ` or by setting `ERDO_ORG`, so the command acts in the org you intended regardless of what any concurrent session did. Runs that build real artifacts enforce this: `erdo eval run` refuses an artifact-building suite unless the org is pinned (see [Evals](/evals#cli)). ## API tokens An API token is an **account-level credential** — it acts as you in any org you belong to, not just one. The org stored on a token is only its **default** (used when a request names no org); `erdo --org ` or `erdo org use ` steers any command to another of your orgs, and the backend re-checks your membership on every request. ```bash theme={null} erdo token create --name ci # mint a token; the secret prints ONCE erdo token create --name ci --expires-days 90 erdo token create --name ci --org acme # set the token's default org erdo token list # your tokens (never shows the secret) erdo token revoke # revoke a token by id ``` Store the token the moment it's printed — it can't be retrieved again. Use it in CI via `ERDO_API_KEY`, or authenticate an interactive session with `erdo login --key `. There is deliberately no way for an AI assistant (MCP) to mint tokens — creation lives only on the CLI/REST surface a human drives. ## Manager accounts A **manager account** operates many client ("managed") orgs with **one** credential — the pattern a portal uses to provision and run an org per customer without a pasted key per tenant. Your active org is the manager (you must be an admin/owner of it); `erdo org managed create` provisions a client org, and `erdo org managed key` mints a single **manager key** that acts inside any managed org via `--org `. ```bash theme={null} erdo org managed create --name "Acme Corp" # provision a client org erdo org managed list # slug name role id erdo org managed key # mint/rotate the manager key; shown ONCE erdo org managed revoke acme-corp-a1b2c3d4 # stop managing (keeps an audit record) # operate a managed org with the manager key: erdo --org acme-corp-a1b2c3d4 datasets list ``` The manager key is one non-expiring credential for every org you manage — target a specific one with `erdo --org ` (or the `X-Organization-ID` header). Running `erdo org managed key` again rotates it. See [Manager accounts](/manager-accounts) for the full portal flow and the REST/MCP surface. ## Project context Use the global `--project ` flag when a command should run inside an Erdo project. Pair it with `--org` whenever the token can operate more than one organization: ```bash theme={null} erdo --org acme project list erdo --org acme --project 01234567-89ab-cdef-0123-456789abcdef datasets list erdo --org acme --project 01234567-89ab-cdef-0123-456789abcdef agent ask "Build the launch page" ``` The project must belong to the selected organization. Project-aware reads are narrowed to its attached resources and, with contributor access, newly-created work is attached to it; organization-wide endpoints stay organization-wide. `ERDO_PROJECT` is the environment-variable equivalent for CI. ## Agents Running an agent is sending it a message; the artifact-builder produces pages this way. ```bash theme={null} erdo agent ask "what was revenue last week?" --datasets sales erdo agent thread --name "landing build" # -> thread id erdo agent send "Build a landing page for ACME ..." --agent erdo.artifact-builder erdo agent send "What should I do next?" --context "Current screen: checkout experiment" erdo agent threads ``` Use `--context` when an application or script knows state that should guide one turn but should not masquerade as the operator's words in the transcript. The agent receives it as application context; the thread still shows the exact message argument as the user message. `ask` and `send` start the run, print its thread id, and poll until it finishes. Agent runs routinely take minutes — building a landing page, screening variants — and a single HTTP request held open that long is cut off by the edge proxy, so polling is the default. The thread id is printed before the wait begins: if you interrupt the CLI, or the run pauses for approval, the work carries on server-side and you pick it back up by thread. ```bash theme={null} erdo agent ask "Build and publish a landing page for ACME with a lead form ..." erdo agent wait # re-attach and wait for the run to finish erdo agent messages # read the full conversation later erdo approvals list --status pending # a run that paused waiting on you ``` A run that paused for approval resumes once you `erdo approvals decide --approve`; `erdo agent wait ` then picks the wait back up and prints the answer when the run completes. Pass `--sync` to hold one request open instead. It returns faster on quick questions, and it will time out on anything long. ## Pages & artifacts ```bash theme={null} erdo pages deploy --title "ACME" --html @page.html --js @page.js --public erdo pages update --js @page.js erdo pages update --title "ACME v2" --html @page.html --css @page.css --public erdo pages validate --html @page.html erdo pages list --query "pricing" --created-after 2026-07-01T00:00:00Z erdo pages get erdo pages delete erdo pages restore ``` `list` shows your pages newest first (id, created-at, visibility, title) and filters by title substring (`--query`) or a created-at window (`--created-after` / `--created-before`, RFC3339). Pass `--type ` to list any artifact type (charts, tables) instead of just pages. `delete` is a **soft delete** — the page's public link stops working and it drops out of `list`, but `restore` brings it back (private, since deleting revoked its public grant). `--html/--js/--css` accept `@path` to read a file. `update` edits an existing page in place (the URL and id stay the same) and **merges** the fields you pass, so you only send what's changing. It takes the same content flags as `deploy` — `--title`, `--html`, `--js`, `--css` (plus the `--datasets` / `--writable-datasets` / `--kv` / `--writable-kv` grants below) — but all are **optional**: pass just `--js` to swap the script while keeping the existing HTML, CSS, and title. `--public` / `--private` change visibility. (Runtime is fixed at create time, so there's no `--runtime` on `update`.) Datasets and KV stores are wired with read/write grants. `--datasets` / `--kv` grant **read** (for `window.erdo.queryDataset` / `erdo.kv.get`); `--writable-datasets` / `--writable-kv` grant **write** (for `erdo.insertRows` / `erdo.kv.set`): ```bash theme={null} erdo pages deploy --title "Lead capture" --html @form.html --js @form.js \ --datasets acme.products \ --writable-datasets acme.leads \ --writable-kv campaign-state ``` Without the writable grant those write calls return a permission error. Public pages capturing data from logged-out visitors should write through an event pipeline (`erdo.submitEvent`) instead — see [Build Apps](/apps/build-apps#writing-data). ## Agent runs Inspect what agents have done (the runs behind ask/send/evals). ```bash theme={null} erdo runs list --agent erdo.artifact-builder --status failed erdo runs get ``` ## Approvals Some agent actions pause for a human decision. List them and approve/reject so the paused run can continue. See [Approvals](/approvals). ```bash theme={null} erdo approvals list --status pending erdo approvals decide --approve # --reject to reject erdo approvals decide --approve --scope always_user ``` ## Decisions What your organization committed to, whether the change actually happened, and what the evidence said afterwards. See [Decisions](/decisions). ```bash theme={null} erdo decisions list # newest first erdo decisions list --applicability standing # the courses currently in force erdo decisions list --subject-ref 24033607833 # everything decided about one campaign erdo decisions list --outcome not_met # what did not do what it promised erdo decisions show # the commitment, its actions, its evidence erdo decisions scorecard # raw counts with their denominators erdo decisions scorecard --since 2026-07-01T00:00:00Z ``` ## Review queue The knowledge agents propose, the investigations they open, and the failure signals they keep counting — the queue of things awaiting a human decision. See [the review queue](/review-queue). ```bash theme={null} erdo reviews list # the open queue (decision items first) erdo reviews list --type knowledge_patch # only proposed knowledge erdo reviews show # full payload (the proposed patch body) erdo reviews decide --apply # apply a knowledge patch, then resolve erdo reviews decide --resolve --note "handled in PR #123" erdo reviews decide --reject erdo reviews decide --snooze 1440 # snooze for 24 hours (default 7 days) ``` ## Datasets ```bash theme={null} erdo datasets list # slug, type, status, name (newest 20) erdo datasets list --limit 100 --offset 100 # page through larger orgs erdo datasets upload leads.csv --name "Leads" # file -> queryable dataset (max 20 MB) ``` `list` shows the newest 20 datasets by default; when more exist it says so on stderr. Use `--limit` (max 100) and `--offset` to page through the rest. `upload` accepts CSV, TSV, Excel, JSON, JSONL, PDF, DOCX, TXT, Markdown, and more — the extension drives type detection. The schema is extracted before the command returns, so the printed slug is immediately usable. Larger files (over 20 MB) go through the web app's resumable upload. ### Reading a dataset There are two reads, and picking the right one matters. Before writing SQL, see what columns are actually there: ```bash theme={null} erdo datasets schema acme.leads # column names and types ``` `schema` reads the stored table itself (a DuckDB `DESCRIBE`), so it shows the physical columns your SQL can reference — including ones added by recent writes that a declared schema may not list yet. `--json` prints the raw result. **`fetch` is the deterministic read.** You write the SQL, so the same command returns the same rows every time — use it for anything mechanical, scripted, or run by an agent. It answers with `columns`, `rows`, and `row_count`. ```bash theme={null} erdo datasets fetch acme.page-events \ --sql "SELECT event, count(*) FROM data GROUP BY 1 ORDER BY 2 DESC" --limit 20 erdo datasets fetch acme.leads --limit 50 # no SQL: just the rows erdo datasets fetch acme.leads --filter # add a saved filter ``` The SQL is DuckDB, and the table is named **`data`** — for file datasets, and for anything an event pipeline writes, that is its name regardless of the dataset's slug. Database and warehouse datasets are queried through their real table names, which come from the dataset's schema. A dataset's default filters apply to every read; `--filter ` adds a saved filter on top and narrows further, never bypassing a default. `erdo datasets filter list ` shows the names a dataset offers. **`query` is the natural-language read.** Erdo writes and runs the SQL for you and answers with that SQL alongside the values, so reach for it when you don't yet know the shape of the data. It runs an agent, so it is slower, and two identical questions can produce two different queries. ```bash theme={null} erdo datasets query sales "top 10 customers by revenue" ``` **A file dataset keeps the versions it overwrote.** Its contents are stored, and every write that replaces them keeps the version it replaced, so an import that overwrote good rows with bad ones has not lost them. List the versions, then read one back with `fetch` — the same SQL you would run against the live table. ```bash theme={null} erdo datasets revisions acme.leads # live version first, then superseded erdo datasets fetch acme.leads --revision \ --sql "SELECT * FROM data" # query those older contents ``` `revisions` prints each version's id, when it was created, when it was superseded (blank for the live one, and for a stored file that was never live), and the stored file's name and type; `--json` prints the raw result. [Dataset revisions](/dataset-revisions) covers what does and does not create one. The history is read-only — nothing here rolls a dataset back. To restore, fetch the rows out of the old revision and write them in again through the normal write path. ## Integrations Connect third-party apps and data sources from the terminal. Which flow you get depends on whether **you hold the credential**, not on whether the app is one of Erdo's native integrations or one of the thousands in the SaaS catalog — see [Connecting integrations](/integrations) for the full picture. Anything authenticated by a secret you already have — a database password, an API key, a service-account JSON — connects in one command with `-c key=value` (repeat it per field). That covers native integrations and catalog apps whose auth type is `keys`. OAuth apps are the exception: the provider mints their credentials during the authorization itself, so there is nothing to pass. They **reject** `-c` with an explanation and, connected without it, print a `connect_url` for the browser; `status` then confirms the result. ```bash theme={null} erdo integrations list # app, status, auth type, name erdo integrations apps slack # search connectable apps # (app, source, auth types, name) erdo integrations connect postgres \ -c host=db.example.com -c port=5432 -c database=analytics \ -c username=readonly -c password=secret erdo integrations connect apollo -c api_key=$APOLLO_KEY -n Apollo erdo integrations connect slack # prints a connect URL to authorize erdo integrations status slack # confirms the connection afterwards erdo integrations tables postgres # list schemas on a connected database erdo integrations tables postgres public # list tables + columns in a schema ``` Every connect names the organization the credential attaches to, because that org is resolved from the pin rather than typed and a connection made in the wrong one is invisible until it starts syncing. The name and slug come back in the response as `organization_name` / `organization_slug` and are printed above the authorize URL, so an OAuth connection can still be abandoned at that point. `erdo integrations connect-links create` says the same thing about the connection its recipient will make. A native credential integration is verified against the provider before the command returns, so `active` means Erdo has genuinely talked to it. A catalog `keys` app is only *stored* — the connector platform saves what it is given without calling the vendor, and the printed `next_step` says so; the first action you run is what confirms the key. `status` on an app you have never connected answers normally — `connected: false` with an empty list. It is a fact about your account, not a failure, so don't read it as one. Once a database is connected, make it queryable as a dataset — queries run live against the source, nothing is copied. Pass the schemas to include (some integrations allow only one); `tables` lists the selectable ones. Per-table column listing is available for SQL databases (Postgres, MySQL, and compatible); warehouses list schemas only. ```bash theme={null} erdo datasets from-integration postgres --name "Production DB" --schemas public erdo datasets configure-integration DATASET_ID --segments public --enable-sync erdo datasets query my-org.production-db "how many orders this week?" ``` ## Knowledge [Knowledge](/knowledge) is your agents' shared brain — definitions, skills, and learnings. ```bash theme={null} erdo knowledge list erdo knowledge search "how do we define active users" --limit 5 erdo knowledge visibility public # publish to external surfaces; 'workspace' to un-publish ``` Entries are `workspace` (organization-internal) by default; `public` opts an entry into anonymous external surfaces such as the [website voice widget](/voice-widget) — a draft is approved in the same step and goes live immediately. See [Knowledge visibility](/knowledge#visibility--workspace-vs-public). ## KV (collections) Named [KV stores](/apps/build-apps#per-page-state-kv) (collections) are Erdo's shared key/value store — the canonical config and values (pricing, targets, brand tokens) that pages read, Knowledge bodies reference as `{{slug.key}}`, and agents resolve. One store, consistent everywhere. ```bash theme={null} erdo kv list erdo kv create pricing # slug: lowercase letters, digits, hyphens erdo kv get pricing monthly erdo kv set pricing monthly '"$29"' # value parsed as JSON, else stored as a string erdo kv set targets q3 '{"arr": 1200000, "logos": 40}' erdo kv delete pricing monthly ``` ## Automations Automations run on a schedule — either an agent that reasons each tick, or a zero-LLM script that runs a deterministic check. See [Automations](/automations). ```bash theme={null} erdo automations list # id, state, name erdo automations run # trigger now erdo automations disable # pause a misbehaving automation erdo automations enable # re-enable a disabled automation # edit in place — every flag is optional and merges over the stored automation erdo automations update --name "Lead alert (v2)" erdo automations update --interval 30 --timezone America/New_York erdo automations update --instructions "Summarise yesterday's leads" # agent automation erdo automations update --script-file alert.js # scripted automation ``` `update` changes only the fields you pass. Edit an agent automation's prompt with `--instructions`, or a scripted automation's body with `--script-js` / `--script-file` — sending the wrong one for the automation's kind is rejected rather than silently ignored. `list` shows each automation's `kind` (`agent` or `script`). ## Evals ```bash theme={null} erdo eval suites erdo eval create landing-variations --agent erdo.artifact-builder --evaluate-artifact --no-cron \ --case '{"name":"voice","input":"{\"artifact_kind\":\"landing_page\",\"description\":\"...\"}","rubric":[{"criterion":"voice widget loads","weight":2}]}' erdo eval update landing-variations --agent erdo.data-question-answerer # only passed flags change erdo eval run landing-variations --watch # CI gate: non-zero if a case fails erdo eval results erdo eval case add --name x --input "..." --rubric '[{"criterion":"...","weight":1}]' erdo eval case add --name y --input "..." --evaluator '{"type":"script","script":"function evaluate(ctx){return {score:5,passed:true,reasoning:\"ok\"}}"}' # multi-step flow: --setup turns run first (same thread/agent), then --input is the evaluated turn erdo eval case add --name voice --setup "Create a voice concierge widget for Lumen Yoga" \ --input "Build the landing page wired to that concierge" --rubric '[{"criterion":"voice widget loads","weight":2}]' ``` See [Evals](/evals) for the evaluator model (LLM rubric vs deterministic script). ## Workstreams [Workstreams](/workstreams) track multi-step business work — campaigns, lead engines, comms loops — with phases, an event log, and overall state. ```bash theme={null} # list / inspect erdo workstream list # all workstreams in the active org erdo workstream list --status active blocked erdo workstream get acme-lead-engine-2026-06 erdo workstream events acme-lead-engine-2026-06 --limit 50 # create erdo workstream create \ --project acme \ --slug acme-lead-engine-2026-06 \ --title "Acme lead engine" \ --description "Stand up the Acme outbound lead engine" # drive erdo workstream phase-add acme-lead-engine-2026-06 --phase-slug brand-brief --title "Brand brief" erdo workstream log acme-lead-engine-2026-06 "Landing pages live, monitoring conversions" erdo workstream set-state acme-lead-engine-2026-06 --status awaiting_user --description "Awaiting brand sign-off" ``` ## Experiments [Experiments](/experiments) are structured tests — hypothesis, variants, decision rule — with an append-only observation log. They live on their own or inside a workstream. ```bash theme={null} # inspect erdo experiment list --status running erdo experiment get acme-cpl-ab erdo experiment observations acme-cpl-ab --type metric_read # the loop's evidence erdo experiment calibration --project acme # each judge's track record vs measured reality erdo experiment policy acme-cpl-ab # decision-policy state (page experiments) # create + drive erdo experiment create \ --project acme \ --slug acme-cpl-ab \ --title "Landing page CPL A/B" \ --workstream acme-lead-engine-2026-06 \ --primary-metric cost_per_lead \ --hypothesis "Variant B's shorter form lifts conversion" erdo experiment set-status acme-cpl-ab running erdo experiment decide acme-cpl-ab --decision ship --outcome "B cut CPL 22% over 2 weeks, shipping" ``` # Core concepts Source: https://docs.erdo.ai/concepts How work starts, progresses, and turns into durable outputs in Erdo. Erdo exposes the work and its result. You do not need to choose the internal AI specialist or tool that performs each step. ## Conversations A **conversation** is the flexible way to work with Erdo. Ask for something in plain language and results such as tables, charts, pages, and files appear inline. Conversations keep their history, so you can return, add context, or ask for a change. Conversations are best for open-ended work. If the task has a fixed sequence and needs durable progress outside the transcript, start from a template instead. An agent's identity is separate from its **deployments**. The agent owns its instructions, knowledge, and skills; deployments let that same worker operate on a website (chat, voice, or video) or over the phone. Channel-specific settings such as voice, greeting, appearance, phone number, and usage limits stay on the deployment. Manage both from **Agents**. ## Activity **Activity** is the operational return loop. It answers four questions: * Is everything okay? * What needs me? * What changed? * What is running? Approvals, attention items, progress, and meaningful results meet here. Activity shows a quiet state when nothing needs action; it is not an automation catalogue or a raw run log. ## Pages A **page** is a durable app Erdo builds for you: a dashboard, report, landing page, or tool connected to live data. Pages run in a managed sandbox under your permissions and can be shared privately or publicly. Pages created in a project appear in its recent outputs. Pin the important ones to keep them on the project home and sidebar. See [Pages](/pages). ## Agents An **agent** is a persistent worker you configure for a repeatable job. It keeps its own instructions, knowledge, skills, data, versions, and activity, and can be tested and improved over time. Build one when the worker itself is something your organisation needs to own and reuse. You do not choose an agent before every conversation. Erdo coordinates its internal specialists automatically for ordinary work; the Agents workspace is for building and operating the durable agents you deliberately create. See [Agents](/agents). ## Templates and progress A **template** is the starting point for a defined, repeatable outcome. It collects the right brief and establishes the stages the work must pass through. Erdo tracks the durable state of that outcome, including: * current stage and overall progress; * anything waiting for your input; * outputs and connected resources; * tests and decisions; * the activity log and source conversation. This keeps a campaign, lead engine, recurring report, or similar body of work understandable without reading every message. Its changes and decisions return through [Activity](#activity). ## Projects A **project** is optional shared context for sustained work. It can collect conversations, progress, learnings, and outputs without forcing every question into a hierarchy. Pinned pages act as its durable output shelf. Quick questions and one-off deliverables do not need a project. Use the project switcher at the top left to change scope, create a project, or open project management. ## Data and connections There are two ways to give Erdo business data: * **Datasets** are files or tables that live in Erdo and can be queried directly. * **Connections** are authorised links to external databases and tools. They let Erdo read data and, with your approval, take action in the connected system. Manage both from **Setup → Data & connections**. Each connection is encrypted. See [Data & connections](/data). Some capabilities depend on a specific connection. For example, Erdo can only book a meeting on a call after Google Calendar is connected. See [Voice](/voice). ## Knowledge **Knowledge** is supporting context Erdo should reuse: business definitions, the shape of your data, approved learnings, and reusable instructions. It keeps future work consistent instead of making every conversation start cold. Manage it from **Setup → Knowledge**; you do not choose an agent or attach skills before starting work. See [Knowledge](/knowledge). ## Review and approvals Erdo keeps a human in the loop. Proposed knowledge can be reviewed, and consequential actions such as sending an email, changing a connected system, or placing a call require approval according to your autonomy settings. Decisions surface in the relevant conversation and in Activity. ## Automations and voice **Automations** run work on a schedule or in response to an event. Their results, failures, and decisions return through Activity. See [Automations](/automations). Erdo can also place outbound **phone calls** to qualify a lead, confirm a detail, or book a meeting. See [Voice](/voice). *** Connect a data source and ask Erdo for a real outcome. # Integration connect links Source: https://docs.erdo.ai/connect-links Send a link to the person who owns the credentials — they complete the connection (OAuth, an API key, database details, or a service-account key) with no Erdo account, and it lands in your organization. # Integration connect links Some of the integrations you want to connect aren't yours to connect. The credentials for a client's CRM, a customer's data warehouse, or a partner's API live with someone else — their IT admin, their ops lead, the person who actually holds the OAuth login or the API key. A **connect link** is how you get that connection done without asking them for the secret or asking them to sign up for Erdo. You generate a link for a specific app and send it to whoever holds the credentials. They open it in a browser, complete the OAuth authorization, paste the API key, or fill in the connection details on a branded screen, and the finished, active connection appears in **your** organization — the credential is stored against your org, never shared back to you in the clear. They never see your Erdo account, and they never need one of their own. ## When to use one Connect links cover both kinds of app Erdo can connect on your behalf: * **Native integrations** — the ones Erdo connects directly, whether that's OAuth, an API key or basic auth, database connection details (Postgres, Snowflake, ClickHouse), or a service-account key (BigQuery). A CRM, a data warehouse, a SaaS API all qualify. * **Pipedream-connected apps** — the much larger catalog of SaaS apps Erdo reaches through Pipedream (Slack, Notion, and hundreds more). Here the recipient authorizes the app on Pipedream's own hosted page and the resulting account reconciles into your organization. Erdo works out which kind an app is from its slug, so you ask for the app the same way regardless. Whichever it is, the point is the same: someone else holds the credentials, and a connect link lets them finish the step without a secret ever passing through your hands. If the credentials are yours, connect the app the normal way from the Connectors tab; reach for a connect link only when someone else has to complete it. The same link also handles **re-authorization**, but only for **native integrations**: when a native integration has expired or been revoked upstream, generate a connect link against it so the credential holder can reconnect it in place, rather than rebuilding it from scratch. A Pipedream app has no in-place credential to refresh this way, so re-auth links don't apply to it — mint a new connect link instead. ## Generating a link In the Erdo platform, go to **Data → Connectors**, find the native connector you want connected, and choose **Invite someone to connect** from its row menu (on a broken connection, **Invite someone to reconnect**). Create the invite link, optionally giving the connection a display name so you recognize it later. You get back a URL to send to the credential holder. Treat that URL as a secret: anyone who opens it can complete the connection into your org, so send it over a channel you trust and revoke it if it goes astray. ## What the recipient sees The link opens a branded page at `/connect` that tells the recipient who invited them and which app they're connecting — no Erdo login, no account creation. * **OAuth apps** send them straight into the provider's own authorization screen. When they approve, the provider redirects back to a finish screen confirming the connection is live. * **API-key or basic-auth apps** show a short form for the key or credentials. Erdo stores them encrypted against your org and, where the connector supports a health check, verifies them on the spot — so a wrong key is caught immediately and they can retry. * **Databases** (Postgres, Snowflake, ClickHouse) show a form for the connection parameters — host, port, database, username, password, and any options the connector declares. Erdo verifies these with a live connection test before completing, so a wrong host or password is caught on the spot rather than failing later. * **Service-account apps** (BigQuery) show a form for the service-account key, which Erdo likewise verifies with a live connection test before the connection completes. The form the recipient sees is built from the connector's own credential schema, so each app asks for exactly the fields it needs and nothing more. When a connector supports more than one method — for example signing in with the provider or pasting a key — the page offers both, and the recipient picks whichever they have access to. Either way, the connection activates in your organization the moment they finish. For a **Pipedream-connected app** the flow is slightly different, because the authorization happens on Pipedream's hosted page rather than on Erdo's own form. The recipient still lands on the same branded `/connect` screen, but from there they're sent to Pipedream's authorization page for the app, approve access, and are bounced back to Erdo's finish screen. Erdo then reconciles the freshly connected account into your organization. Because that reconciliation can lag a beat behind the recipient finishing on Pipedream's side, the finish screen offers a **Check again** action: if the connection hasn't registered yet, the recipient taps it and the page confirms as soon as the account lands — no need to restart the flow. ## Expiry, revocation, and single use A connect link is deliberately short-lived and single-purpose: * **It expires 14 days after you create it.** An expired link stops resolving — the recipient sees an unavailable page rather than a connection form. * **It completes once.** After the recipient finishes the connection, the link is spent and can't be reused to create another. * **You can revoke it any time** before it's completed. Revoking invalidates the URL immediately, so a link sent to the wrong person is easy to shut off. Failed connection modes never leak information: a revoked, expired, or unknown link all resolve to the same generic unavailable screen, so the URL never confirms whether a link ever existed. ## API, MCP, and CLI If you're provisioning connections from a portal or automating onboarding, the same capability is on the `/v1` REST API, the MCP tools, and the CLI. Everywhere, you name the app by its **slug**, not an internal id — a native integration slug (for example `github` or `hubspot`) or a Pipedream app slug (for example `slack` or `notion`) work the same way; Erdo routes to whichever kind the slug names. ### REST Base URL `https://api.erdo.ai`. Authenticate with `Authorization: Bearer `. ```bash theme={null} # mint a link for a native or Pipedream app — link_url is a secret curl -X POST https://api.erdo.ai/v1/integration-connect-links \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"app": "hubspot", "name": "Acme Corp HubSpot"}' # list your connect links (optionally filter by app) curl https://api.erdo.ai/v1/integration-connect-links?app=hubspot \ -H "Authorization: Bearer $ERDO_API_KEY" # revoke a pending link by id curl -X POST https://api.erdo.ai/v1/integration-connect-links//revoke \ -H "Authorization: Bearer $ERDO_API_KEY" ``` `POST /v1/integration-connect-links` returns `{ link_id, link_url, expires_at, app }` — send `link_url` to the credential holder. Pass `integration_id` in the body to re-authorize an existing, non-active integration instead of creating a new one; that path is for native integrations only, and the API rejects it for a Pipedream app. ### CLI ```bash theme={null} # mint a link for a native or Pipedream app — link_url is printed; the guidance goes to stderr erdo integrations connect-links create hubspot --name "Acme Corp HubSpot" erdo integrations connect-links create slack --name "Client Slack" # list your links: id app status new|reauth expires_at link_url erdo integrations connect-links list --app hubspot # revoke a pending link erdo integrations connect-links revoke ``` ### MCP tools | Tool | What it does | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `erdo_create_integration_connect_link` | Mint a shareable link for a native or Pipedream app, named by slug. Returns the link URL — treat it as a secret. | | `erdo_list_integration_connect_links` | List your connect links (app, status, re-auth, expiry, URL), optionally filtered by app slug. | | `erdo_revoke_integration_connect_link` | Revoke a pending link by its id so the URL stops working. | Connect links work for both native integrations and Pipedream-connected apps; re-authorization is native-only, so the create tool rejects `integration_id` for a Pipedream app. Identity and RBAC ride the request context, so a caller only ever mints links for their own organization. # Which account a connection uses Source: https://docs.erdo.ai/connection-accounts Connecting an advertising platform grants access to every account your login can see, so each connection records which one it operates — chosen when you connect, and changeable afterwards from the app, the API, or the CLI. # Which account a connection uses When you connect Google Ads, Meta Ads, TikTok Ads, Reddit Ads, or Google Analytics, you authorize Erdo against a *login*, not against an account. That login usually reaches several accounts: a marketing agency's Google account can see every client it manages, and a Meta login can see every ad account under every Business Manager it belongs to. The grant covers all of them. That leaves one question the authorization itself cannot answer — **which of those accounts is this connection for?** It is a property of the connection rather than of any one dashboard or dataset, because it decides the account for everything the connection does: every dataset built on it, every sync it runs, and every action an agent takes through it. So Erdo asks as soon as you connect, and records the answer on the connection. ## Choosing when you connect After you authorize the provider, Erdo lists the accounts that connection can reach and asks you to pick one. For Google Ads that includes accounts reachable only through a manager (MCC) account — Erdo asks each account it can see directly whether it manages others, so client accounts appear even though the provider does not list them directly. Pick the account and the connection is ready. Everything Erdo does through it from then on runs against that account. **One account per connection.** To work with a second account, connect the integration again and choose that account. Two connections to the same provider are normal and expected — an agency typically has one per client. **Choosing is optional.** Connecting often comes *before* the account exists: you authorize Google Ads or Meta so that Erdo can go and create an ad account for you, and until it has, there is nothing to choose between. Skip the question and the connection is made anyway. Erdo records the account it creates against the connection, and you can set or change it yourself at any point. A connection with no account recorded cannot read or write anything at the provider yet, so Erdo says so where it matters rather than blocking the connection: building a dataset on one asks for the account before it will read. ## Changing it later The choice is not permanent, and skipping it is not final. In the app, find the connection under your connectors and use **Account** on it. Over the API or CLI, read the accounts a connection can reach and set the one it should use. ```bash CLI theme={null} # Which connections do you have? erdo integrations list # Which accounts can this one reach, and which is it using? (* marks the current one) erdo integrations account show 53bea27a-891a-42ef-88b1-e70fc9fdf7dd # Use a different one erdo integrations account set 53bea27a-891a-42ef-88b1-e70fc9fdf7dd 8834039525 ``` ```bash REST theme={null} curl -H "Authorization: Bearer $ERDO_API_KEY" \ https://api.erdo.ai/v1/integrations/$INTEGRATION_ID/connection-scope curl -X POST -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"account_id": "8834039525"}' \ https://api.erdo.ai/v1/integrations/$INTEGRATION_ID/connection-scope ``` Over MCP the same two operations are `erdo_get_connection_scope` and `erdo_set_connection_scope`. ## What comes back ```json theme={null} { "integration_id": "53bea27a-891a-42ef-88b1-e70fc9fdf7dd", "has_connection_scope": true, "description": "Choose the Google Ads account this connection manages.", "options": [ { "id": "8834039525", "name": "2200 Brickell", "type": "account" }, { "id": "5302012239", "name": "Erdo AI", "type": "account" } ], "selected": [{ "id": "8834039525", "name": "2200 Brickell", "type": "account" }], "required": true } ``` | Field | Meaning | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `has_connection_scope` | Whether this integration chooses an account per connection at all. **False** for a database connection, which names its database in its credentials — there is nothing to choose. | | `description` | What the choice means for this provider, for showing to a person. | | `options` | The accounts this connection can reach. Address them by `id`; `name` is for showing a person which is which. | | `selected` | The account the connection currently operates. Empty when none has been chosen. | | `required` | Whether the connection can resolve an account at all before one is chosen. When `true`, datasets and actions on this connection fail until it is set — a consequence to plan around, not a gate: the connection is made either way. | Address an account by the `id` the options list gives you, exactly as it appears. Account ids are not numeric everywhere — a Meta ad account is `act_1234567890` and a Reddit one is `a2_bzv32cgqp` — and a Google customer id is accepted in either the `530-201-2239` form its UI shows or the `5302012239` form its API wants. Some providers reach an account through an owner — a Reddit ad account belongs to a business, a GA4 property belongs to an account. Those appear as `parent` on the option, and setting the account records the whole path, so you never have to name the owner separately. ## Filtering within the account Choosing the account is not the same as narrowing what a dashboard reads. Once a connection knows its account, a dataset built on it can still filter to specific campaigns, lists, or flows — that filter belongs to the dataset, because two dashboards on one connection routinely want different slices of the same account. You will see both: the account on the connection, and the campaign filter on the dataset. ## If an account is missing The list holds exactly what the provider says that login can reach. If it is empty because you do not have an account yet, that is the expected state — leave it, and set the account once it exists. Otherwise a missing account is almost always a permissions question at the provider rather than in Erdo: * **The login lacks access.** Grant it access in the provider's own UI, then reconnect. * **The account sits under a manager the login cannot see.** Google Ads client accounts are found by asking each directly-accessible account what it manages, so a client account is only reachable if the login can see its manager. * **The connection needs reauthorizing.** A connection whose grant expired cannot list anything. Reconnect it and the accounts return. # Cross-customer priors Source: https://docs.erdo.ai/cross-customer-priors How Erdo learns aggregate priors from every organization's experiments — what is shared, what never is, and how to opt out. # Cross-customer priors Every experiment you decide is a data point about how a kind of change moves a kind of metric. On its own it benefits only your organization. Pooled across every organization on Erdo, those data points become something no single company could build alone: a library of "changes of this structural kind tend to move this metric by about this much." Erdo distills that library nightly and uses it as a **zero-information starting belief** when planning a new experiment — a prior for which treatment kinds are worth trying, and roughly how large an effect to expect, before your own traffic has said anything. This page is the disclosure of exactly what that library contains, what it can never contain, and how to remove your organization from it. ## What is shared: aggregate effect sizes over a fixed set of change kinds The only thing the platform learns is the **aggregate effect of a structural treatment class on a metric**, pooled across organizations. A treatment class is a coarse, structural label for what kind of change a variant makes — never what the change says. There are exactly eight, and the set is fixed: * `headline_change` — the headline/hook copy changed * `hero_media_change` — the hero image or video changed * `cta_change` — the call-to-action changed * `form_length_change` — the lead form gained or shed fields * `social_proof_add` — testimonials, logos, ratings, or counts were added * `layout_reorder` — the same sections in a different order * `offer_change` — the underlying offer, price, or incentive changed * `full_page_rebuild` — several dimensions changed at once When an agent creates an experiment variant, it **declares** which of these classes the variant belongs to. The distiller reads that declared label and the measured outcome — it never reads your page's content, copy, or design. Eight classes is deliberate: coarse enough that no aggregate can be traced back to one organization, specific enough to carry a useful prior. For each (treatment class, metric) pair, a published prior contains only: * how many experiments and how many distinct organizations contributed, * a five-number summary (minimum, quartiles, maximum) of the **relative effect** of the winning variant versus its control, and * the share of those experiments in which the effect was positive. The test we hold this schema to is simple: every published cell is something we would be comfortable printing on our marketing site. ## What is never shared No page content, copy, headline, image, or design. No organization name, slug, or identifier. No per-experiment result, and no per-organization number of any kind. A published prior is an aggregate and nothing but an aggregate — there is no field in it that names or reconstructs any single organization's data. Two thresholds enforce this by construction: a (treatment class, metric) cell is published **only when at least five experiments from at least three distinct organizations** have contributed to it. Below either threshold the cell publishes nothing at all. An aggregate over five experiments from three organizations cannot be attributed to any one of them. ## How the prior is used A cross-customer prior is the **outermost** belief in Erdo's experiment decision policy: it is overridden by your own calibrated [persona-panel](/persona-panel) prediction, and by any real traffic the moment it arrives. Its weight is capped so that a day or two of your own visitors washes it out entirely. In practice it does one useful thing — it stops an obviously-oversized experiment before it launches and nudges the opening traffic split — and then it gets out of the way of your real data. ## Reading the library The published library is visible to every organization, verbatim — there is nothing hidden about it. Read it through any Erdo surface: * **MCP:** `erdo_list_cross_customer_priors` * **REST:** `GET /v1/cross-customer-priors` * **CLI:** `erdo experiment priors` Each entry is one (treatment class, metric) cell with its counts and five-number effect summary. ## Contributing, and how to opt out Contribution is **on by default**, because only de-identified aggregates over many organizations are ever published. If you would rather your organization's experiments not contribute, an organization admin can turn it off in **Settings** (the `contribute_aggregate_learning` toggle). Opting out takes effect at the **next nightly run**: your experiments are dropped from the aggregation entirely, and any published cell that no longer clears the five-experiment / three-organization thresholds without your data is removed — there is no residue. You keep every benefit of your own experiments and your own [calibration](/persona-panel); you simply stop contributing to the shared library. # Custom domains Source: https://docs.erdo.ai/custom-domains Serve your published pages from your own hostname — register a branded domain, watch its DNS and certificate go live, and move it between organizations without downtime. # Custom domains Published pages normally serve from Erdo's pages host. A **custom domain** puts them on a hostname you own instead — `pages.acme.com` rather than `pages.erdo.ai` — via a single CNAME record. The domain belongs to your **organization**, not to one page: once active, every public page the org publishes is reachable on it, and share links are built on it automatically. ## How a domain goes live 1. **Register the domain.** Use a direct subdomain of a domain you control (e.g. `pages.acme.com` — a root domain can't carry the CNAME). Registration returns the DNS records to create. 2. **Create the DNS records.** A routing CNAME pointing at Erdo's pages host, plus a one-time `_acme-challenge` delegation CNAME so certificates can issue and renew without you ever touching DNS again. 3. **Wait for validation.** Erdo re-checks the domain continuously: ownership is verified from the routing CNAME, then a certificate is issued, then the domain starts serving. The domain's `status` tells you exactly where it is in that walk: | Status | Meaning | | -------------- | ------------------------------------------------------------------------------------------------------------------ | | `pending_dns` | Waiting for the routing CNAME to appear at your DNS provider. | | `pending_cert` | Ownership verified; the certificate is issuing. | | `active` | Serving traffic. | | `failed` | Something is actually wrong — `error_reason` says what (most commonly a CAA record blocking certificate issuance). | | `disabled` | The registration no longer exists upstream. | Status is read from the same record the page-serving path uses — never a cached copy that can drift — and `last_checked_at` says when it was last reconciled, so a domain that has sat in `pending_cert` for a month is visible for exactly what it is. ## API All endpoints act on the caller's organization and require an **org admin**. Domains are addressed by the domain name itself — a name registered to another organization answers `404`, with no hint that it exists. List the org's domains with live status: ```bash theme={null} curl https://api.erdo.ai/v1/custom-domains \ -H "Authorization: Bearer $ERDO_API_KEY" ``` Register a domain (the response carries `dns_records` — the records to create): ```bash theme={null} curl -X POST https://api.erdo.ai/v1/custom-domains \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "pages.acme.com"}' ``` Remove a registration (the hostname stops serving pages): ```bash theme={null} curl -X DELETE https://api.erdo.ai/v1/custom-domains/pages.acme.com \ -H "Authorization: Bearer $ERDO_API_KEY" ``` On the CLI: `erdo pages domains list | add | remove | transfer `. The MCP tool `erdo_list_custom_domains` gives agents the same read; mutations are deliberately REST/CLI-only. ## Moving a domain between organizations When a customer's pages are rebuilt in a new organization — a tenant migration, a portal cutover — the branded hostname has to start serving the new org's pages. Deleting and re-registering it would tear down its CDN hostname and force certificate re-issuance: a visible outage on a live domain. **Transfer** avoids that entirely: ```bash theme={null} curl -X POST https://api.erdo.ai/v1/custom-domains/pages.acme.com/transfer \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"target_org": "acme-new"}' ``` The domain keeps its CDN hostname and certificate; only its owning organization changes, so serving flips to the target org atomically with the move — zero downtime, nothing to re-validate, no DNS changes for the customer. Transfer is **two-sided by construction**: you must be an org admin of the organization that owns the domain *and* of the target organization. A [manager account](/manager-accounts) satisfies both when steering a domain between its own managed orgs — which is exactly the cutover case — and nobody who administers only one side can move a domain in or out. Transfers are idempotent: re-running one that already happened succeeds without changing anything. ## Scope and limits * Domains must be **direct subdomains** (`pages.acme.com`, not `acme.com` or `pages.team.acme.com`). * A domain can be registered to **one organization at a time**, platform-wide. * Each organization can hold up to **25** custom domains. * Registration, validation, and certificate mechanics are platform-owned — you only ever create the DNS records the API hands you. # Data & Connectors Source: https://docs.erdo.ai/data Give your agents something to work on — upload files, connect your tools, bring your semantic layer, and trust that the answers are right. Agents work on **your** data. Erdo's job is to understand the shape of that data, let agents query it directly, and make sure the answers that come back are actually correct. You bring data in from the **Data** section in a few ways. Connecting data sources in Erdo ## Upload a dataset Drop in a file and it becomes a **dataset** your agents can query immediately: * **Tabular** — CSV, Excel (`.xlsx`, `.xls`) * **Documents** — PDF, Word, PowerPoint, Markdown, text, JSON, and more Erdo analyzes every file the moment it lands, so agents reason over real structure instead of guessing. ### What Erdo works out on upload For tabular data, the analysis goes well beyond "read the first row as headers": * **Schema & types** — every column is profiled and typed (number, date, text, boolean), with null counts, distinct-value counts, and ranges. * **Messy real-world files** — Erdo finds the *actual* header row in report-style exports that start with titles, logos, or blank rows, and merges multi-row headers (e.g. `Revenue / Q1`, `Revenue / Q2`) into clean column names. * **Number formats** — thousands separators, currency symbols, percentages, and accounting-style negatives like `(1,234)` are recognised as numbers, not text. * **Currencies** — an ambiguous `$` column is resolved using nearby country/region columns where possible, so totals aren't silently mixed. * **Time series** — date columns get their range and **gaps** detected, so an agent knows when a month is missing before it reports a trend. For documents, Erdo extracts the text and structure so agents can search, summarise, and cite from them. This upfront analysis is why an agent can answer a question about a freshly uploaded file straight away — it already knows the columns, types, and quirks. ## Connect a source A **connector** is an authorized link to an external tool or system — your database (PostgreSQL, Snowflake, BigQuery, ClickHouse), Google Workspace, Slack, Shopify, Stripe, GitHub, and many others. Connecting one lets agents read from it and, with your approval, act in it. Connect **Meta Ads**, for instance, and agents can read across the account — listing the Business Manager portfolios you belong to, the Facebook Pages available for ad creatives, the conversion pixels already in place, and the Instant Forms on a page along with the leads they've collected — while anything that changes the account, such as creating a new pixel, uploading a video to the ad account's library for a video ad, or creating an Instant Form for a lead ad, waits for your approval first, as every Meta Ads write does. Find the tool you want and click connect. Authorize through the provider. Connections are **per user** and encrypted — each person connects their own accounts. Agents can now pull from the source. Sources that sync (like a database or a store) turn their tables into queryable [datasets](#upload-a-dataset), schema-analyzed just like an uploaded file. **Dataset vs. connector:** a *connector* is the source (the authorized link); a *dataset* is the queryable data that lands in Erdo. One connector can produce many datasets. In day-to-day use you'll mostly think in terms of "connect Shopify" and then "query my Shopify orders." ## Connect a data lake If your data is **parquet files in object storage** (GCS or S3), connect it as a [data lake](/data-lake) and Erdo queries it **in place** — no copy, no warehouse. Each folder under your lake root becomes a queryable table. ## Connect a custom API If a tool isn't in the gallery but has an API, point Erdo at its API docs and an agent will generate a client for it. You then connect with your API key and query it like any other source — no code to write yourself. ## Bring your semantic layer If you've already defined your business model in **dbt**, **LookML (Looker)**, **Power BI**, or **Tableau**, you don't have to redefine it in Erdo. Import the model and Erdo turns it into [knowledge](/knowledge) your agents share: * **Metrics** — measures and their formulas (revenue, ROAS, CAC) become canonical definitions, so every agent computes them your way. * **Entities & dimensions** — your business objects (campaigns, orders, customers) and their fields, mapped back to the tables they live in. * **Relationships** — join conditions between models are preserved, so agents know how your tables connect. * **Row-level security** — access filters from LookML carry over as mandatory query constraints, so agents respect the same boundaries your BI tool does. You review the import before anything becomes shared truth, and agents can propose refinements over time. See [Knowledge](/knowledge) for how these definitions are used. ## How agents query your data You never write the query — you describe what you want, and the agent picks the right path: * **SQL** for direct questions over a dataset or synced table — fast filtering, joins, and aggregation. Results come back as a new dataset you can query again. * **Python** for multi-step analysis, statistics, and API-only sources (like Google Analytics or ad platforms) where a single SQL query won't do. When a connected source has been synced into Erdo, agents query the synced tables directly rather than guessing at a provider's API — so answers come from your real, current data. ## Getting answers right This is where Erdo is different. An agent doesn't just run a query and report whatever comes back — it **digs in and checks its own work** before showing you a result: A revenue dashboard Erdo built from a connected dataset * **Does this answer the question?** A separate quality check confirms the analysis addresses what you actually asked — the right metric, the right grain, the right time window — not a plausible-looking proxy. * **Trust the data, not the assumption.** If the data disproves the obvious approach (a join key that's empty, a column that doesn't exist, a filter that returns nothing), the agent adapts and re-runs instead of forcing the original plan. * **Catch the silent failures.** Empty result sets, all-zero or all-null columns, suspiciously small counts, and contradictory totals are flagged and investigated rather than presented as fact. * **Decision-quality verdicts.** For "did this work?" questions — experiments, launches, funnels — Erdo insists on comparable before/after windows and a real primary outcome, so a rise in vanity activity doesn't get reported as success. * **Validated refreshes.** When a synced dataset refreshes, Erdo checks invariants like key uniqueness so a broken sync doesn't quietly corrupt later answers. The result: agents that double-check, correct course, and tell you when something doesn't add up — instead of confidently returning the wrong number. ## Tables and scratch datasets Not every dataset is meant to stick around. Erdo distinguishes two kinds: * **Tables** are the durable datasets your flows and uploads write to — your leads, your campaign metrics, a file you brought in. They're the data you come back to, so they're what listings show by default. * **Scratch datasets** are analysis by-products: the intermediate result of a question an agent worked through, a one-off export, a `sql_results` file. They pile up fast and you rarely need them later, so they're **hidden from dataset listings by default** to keep the durable data easy to find. Nothing is deleted — scratch datasets are just out of the way. When you want to see them, ask for all classes: over the API and MCP, pass `class=all` (or `class=scratch` for only the by-products); on the CLI, `erdo datasets list --class all`. A dataset you address directly by slug always opens regardless of its class. Integration-backed tables also carry their source connection in dataset-list responses (`integration_id` and `integration_config_key`) plus their current `sync_status`. External consumers can therefore discover, for example, the tables produced by a connected source without guessing a dataset name or slug. The fields are generic provenance: the dataset API does not grow separate campaign, ad, invoice, or contact endpoints. For a dataset with several tables, read its schema and pass the selected resource's `key` as `resource_key` to the ordinary dataset query. For file datasets (CSV/Excel), the SQL table is named `data`; for synchronized API integration datasets, the SQL table name matches the resource name (e.g. `campaigns`, `orders`). The class isn't fixed: if a durable table was mistakenly filed as scratch (or the reverse), pass `class` (`table` or `scratch`) to the update-schema surface — `POST /v1/datasets/{id}/schema` or the `update_dataset_schema` tool — to reclassify it; omit it to leave the class unchanged. ## Declared schemas A table can carry a **declared schema** — a column contract that says which columns the table has, which are required, and what type each holds. It exists so that anything reading the table (your app, a report, another agent) can read the column list once from the schema instead of guessing at names, and so writes stay consistent instead of quietly growing a new column every time a payload changes. A table's schema is set the first time it receives data — the columns of that first batch become the contract — and you can declare or change it explicitly at any point. Ask the agent, or use the schema tool directly: a `declare_schema` operation replaces the whole contract, and `add_column` / `rename_column` / `remove_column` / `alter_column_type` evolve it a column at a time. The declared schema is returned alongside a dataset's columns on the schema endpoint (`GET /v1/datasets/{id}/schema` and the `get_dataset_schema` tool). Once a table has a declared schema, writes are held to it — but how a mismatch is handled depends on where the write comes from, because dropping a real submission is never acceptable: * **API and agent writes** with columns the table doesn't declare, or missing a required column, are **rejected** — the error names the offending columns so you can add them to the schema (`add_column`, or a new `declare_schema`) and retry. This keeps deliberate writes from silently drifting the schema. * **Form and event captures** (a landing-page lead form, an event pipeline) are **never dropped**: the record is written as submitted even if it doesn't match, and the mismatch is recorded on the dataset's timeline as an event naming the columns that drifted. You keep every lead and still get told the schema moved. Tables without a declared schema — and scratch datasets — accept whatever columns you write, exactly as before. ## Filtering what shows up A **filter** is a saved rule on a dataset that hides rows you don't want to see by default. Once you add one, it applies everywhere that dataset is read — lead lists, dashboards, public pages, and the agent's own queries — including counts and totals, not just the row list. Filters are the right tool whenever a dataset accumulates rows you'd rather not look at by default. The most common case: keeping **test and QA submissions** out of a landing page's real leads — you capture everything, then filter out the test rows by a value they share (a test email, a flag, a source). Other uses: hide a status (`status` is `archived`), or scope to a window (`created_at` after a date). You don't manage filters by hand — just ask: > "Add a filter to the leads dataset to hide submissions from anyone testing — their > emails all end in `@example.com`." The agent adds the filter (you can also list or remove them), and from then on every view of that dataset excludes those rows. Need to see everything again — including the filtered rows — for a one-off check? Ask the agent to query with filters off, or remove the filter. Filters hide rows from view; they never delete anything. You can also manage filters yourself — in a dataset's settings, or from the CLI, MCP, and REST API. See [Filters](/filters) for every way to add, list, and remove them. A refresh that goes wrong is recoverable for the same reason: a file dataset keeps the contents each replacement overwrote, so the version from before a bad rebuild is still readable. See [Dataset revisions](/dataset-revisions). Some sources also let you scope what comes in right in the segment picker when you connect, and where a source is hierarchical Erdo groups those choices by the container that owns them. Connect **Meta Ads**, for instance, and you choose one ad account for the connection, with each account listed under the Business Manager that owns it — accounts outside any business appear under a "Personal" group. Erdo remembers the owning business alongside the account, and the connection is named after your choice. Working across several ad accounts is just several connections: connect Meta Ads once per account. ## Who can see what Data access follows your organization's permissions. Connections are private to the person who made them; datasets are shared according to your workspace's sharing rules. Agents only ever see data the acting user is allowed to see. # Data Lakes Source: https://docs.erdo.ai/data-lake Connect your parquet data lake on GCS or S3 and query it in place — no copy, no warehouse. If your data already lives as **parquet files in object storage** (a "data lake"), Erdo can query it **where it is**. You don't copy anything into Erdo, and you don't need a warehouse like BigQuery or Snowflake in the middle — Erdo reads the parquet directly with DuckDB over the object store. This is the right connector when you have a lake like: ``` gs://your-bucket/warehouse/ events/year=2026/month=06/day=25/*.parquet orders/year=2026/month=06/day=25/*.parquet ``` Each top-level folder (`events`, `orders`, …) becomes a **table** you can query. [Hive-partitioned](https://duckdb.org/docs/data/partitioning/hive_partitioning) columns like `year`/`month`/`day` are real, queryable columns too. ## What you'll need Connect the same way as **BigQuery** — either: * **Sign in with Google** (OAuth, read-only storage scope), or * paste a **service-account JSON** for an account with read access to the bucket (`roles/storage.objectViewer`). No HMAC interoperability keys (which many orgs disable by policy). An **access key id + secret** with read access. Works with AWS S3 and S3-compatible stores (Cloudflare R2, MinIO, Backblaze B2, …) — supply the custom **endpoint** for those. The credentials only need **read** access — the connector never writes to your lake. ## Connect it Choose the **Data Lake** connector. * **Provider** — `gcs` or `s3` * **Root** — e.g. `gs://your-bucket/warehouse` or `s3://your-bucket/warehouse` * **Credentials** — **Sign in with Google** or paste a **service-account JSON** (GCS); or an **access key + secret** (S3) * **Region / endpoint** — for S3 / S3-compatible stores (optional) Erdo lists the tables it finds under the root, with their columns. Select the ones you want to make queryable — they become [datasets](/data#upload-a-dataset) just like any other connected source. Ask an agent a question, or build a [page](/pages) on top. Agents query your lake with SQL and reason over the results — the parquet never leaves your bucket. ## How queries run Erdo runs each query in a sandboxed DuckDB that reads your parquet over the network using your credentials. A few things worth knowing: * **In place, no copy.** Data stays in your bucket; Erdo streams only what a query needs (parquet column + partition pruning keeps reads small). * **Scoped to your tables.** Agent-generated SQL can only read the tables you selected — it can't be pointed at arbitrary paths, even within the same bucket. * **Partition-aware.** Filter on the Hive partition columns (`year`, `month`, …) and Erdo only scans the matching files. **Lake vs. warehouse.** Connect a **data lake** when your source of truth is parquet files in object storage. If your data lives in a query engine (PostgreSQL, Snowflake, BigQuery, ClickHouse), connect that [database](/data#connect-a-source) instead — Erdo will query it natively. # Arrival notifications Source: https://docs.erdo.ai/dataset-notifications Email a group of people the moment new rows land in a dataset — declared as a recipient list, not hand-written automation code. Speed-to-lead is the whole game: a form submission that nobody hears about for four hours is worth a fraction of one somebody calls back in ten minutes. An **arrival notification** closes that gap. You name a dataset and a list of addresses, and Erdo emails those people within seconds of a new row arriving. You declare *who* and *where*. Erdo owns everything underneath — the automation, its trigger, the watermark that separates new rows from old ones, and the message itself. There is no script to write and nothing to keep in sync. ## Why it attaches to a dataset Notifications hang off the **dataset**, not off the pipeline that fills it, and that distinction matters more than it first appears. A real development runs many lead-capture pipelines — one per landing page, per language, per variant. One customer has 25 of them, all writing a single leads dataset. A notification attached to a pipeline covers that pipeline alone, so the day someone launches landing page 26, its leads stop reaching anyone and nothing looks broken. Attaching to the dataset covers every writer, including the ones that don't exist yet. ## Setting it up `PUT /v1/dataset-notifications` takes the dataset slug and the full recipient list. The list **replaces** what was there — send the complete set, not a delta. ```bash theme={null} curl -X PUT "https://api.erdo.ai/v1/dataset-notifications" \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "dataset": "2200-brickell.2200-brickell-leads", "recipients": ["desk@example.com", "ana@example.com"] }' ``` `GET /v1/dataset-notifications?dataset=` reads the current state back — recipients, whether it's on, and the automation implementing it. That state is read from the automation itself, so there is no second copy of the setting to drift out of sync with reality. Passing an **empty recipient list turns notifications off**. Turning notifications on never delivers a backlog. Where the dataset currently stands is recorded at the moment you configure the notification, so only rows arriving afterwards are announced — including the very first one. Turning notifications off removes the automation rather than pausing it, for the same reason: switching back on months later must not dump everything that accumulated in between into someone's inbox. ## What arrives Ordinary arrival sends **one email per row**, so each lead is its own actionable message: who they are and how to reach them first, then what they told the form, then the campaign that brought them. Tracking identifiers — click ids, page URLs, the flags the pipeline stamps on the way in — are join keys for machines and appear nowhere in it. The language they submitted in does appear, because it decides who on the desk can take the call. A burst collapses into a single digest instead of flooding the inbox: either more rows than one run reads, or more messages than one run may send. Every message is tagged with [context](/sent-email) recording the dataset and which row it concerned — `{"kind": "lead_alert", "lead_email": "ana@example.com"}` — so your own product can later show *"we alerted the team about this lead"* on the lead itself. This is the only way that link can be made: the recipient is the sales desk, so nothing about the address says which lead the message was about. ## The same lead arriving twice A lead dataset is not a log. Capture pipelines **upsert** on the address, so a landing page that asks for an email first and the rest a minute later writes the same lead twice — one row in the dataset, two writes. Announcing that as two leads is wrong, and it is not a rare shape: on one development a quarter of all leads are written more than once, far enough apart that no amount of batching would hide it. The write settles it. Every row records the moment the dataset created it, in a column named `erdo_created_at`, and a second write to the same row leaves that moment alone however far the arrival column moves. A notification keeps its position in that column as well as in the arrival column, so a row created no later than the point it has already reached is a lead it has announced — including one that arrived in a burst too large for a single run to read. On top of that it also remembers which leads it has announced by address, and what was known about each at the time, which is what covers rows written before `erdo_created_at` existed. Either way a row it recognises is not announced again, and the run result counts it. Two leads that both arrived without an address are still two leads — an absent address identifies nobody, so neither is suppressed. ## Cost Nothing per run. The automation is a deterministic script fired by an event trigger on the dataset — no LLM, and no polling loop burning tokens to ask whether anything changed. It runs when a row lands and is otherwise idle. Don't build this with a recurring agent or a polling heartbeat. A five-minute heartbeat doing this job once cost a customer roughly \$81 in a single week to notice one or two leads a day — while also being up to five minutes slower than the event trigger. ## Skipping rows you describe Not every new row deserves an email. Public lead forms attract junk — typo'd addresses that can never receive a reply, gibberish, staff tests — and some desks simply don't want alerts for rows missing what they need to act. Which rows those are is your call, not the platform's, so you describe them: ```json theme={null} { "skip_when": "spam, test submissions, or gibberish" } ``` or ```json theme={null} { "skip_when": "anyone who did not leave a phone number" } ``` Each new row is judged against your description before its email, and a row that matches sends no alert. The judgement is instructed to be conservative: when unsure, the row does not match and the alert is sent. Where the row carries an email address, the judgement also receives a piece of evidence: a DNS lookup of the address's domain, stated only when it returns a definitive *this domain does not exist*. The fact informs your criterion rather than overruling it — "spam" will weigh a non-existent domain heavily, while "anyone who did not leave a phone number" will rightly ignore it. A skipped row is never silently dropped: the automation's run result records each one with its address, name, and the reason, and the run summary counts them. **The row itself is untouched** — it still lands in the dataset and still appears in every count; only the alert is suppressed. The check **fails open**. If the judgement errors, the email sends — a broken skip check must never become a broken alert channel. The gate applies only to per-row alerts; a bulk import's digest is already one bounded message and is delivered as before. An empty `skip_when` means every new row is announced. ## Which column marks arrival Erdo detects the column recording when a row arrived (`submitted_at`, `created_at`, `received_at`, and similar) by probing the dataset's real columns when you configure the notification. If your dataset names it something unconventional, pass `timestamp_column` explicitly. Detection happens at configuration time on purpose: a dataset with no usable column is an immediate error you can fix, rather than an automation that looks perfectly healthy and quietly never notifies anyone. ## The recipient list is also the permission Sending email is a consequential action, so an automation cannot do it unless something authorises it — and an unattended run has nobody to ask, which is why a script that emails on a schedule needs a standing approval granted in advance. A notification does not need one, because the recipient list *is* the authorisation. Saving a notification records permission for that automation to email exactly the addresses you listed. Remove an address and the permission goes with it; turn notifications off and it is withdrawn entirely. The automation cannot reach an inbox you did not name, even if something goes wrong with the message it composes. This is the difference between a notification and a hand-written script that sends mail. The script names its recipients in code nobody reviewed as a decision about who may be written to, so it is refused until somebody grants it. A notification is that decision, made when you saved it. ## Limits At most 10 recipients per dataset — each recipient's email is one action invocation, and one run may make ten, so a longer list could never be delivered. Sends also count toward your organization's daily distinct-recipient limit of 50. ## Related Read back everything that went out, including these alerts. The run history of the automation behind a notification. # Dataset revisions Source: https://docs.erdo.ai/dataset-revisions A file dataset keeps the versions it overwrote — list them, query one, and recover rows a bad refresh dropped # Dataset revisions Erdo stores a file dataset's contents as a file. Every write that replaces those contents — a refresh, a re-import, an upload that overwrites — stores the new file alongside the one it replaced rather than on top of it, so the previous version is still there after the new one goes live. A **revision** is one of those stored versions. This matters on the day a refresh goes wrong. If a source briefly returned fewer rows, or a rebuild ran in replace mode when it should have merged, the live dataset now holds less than it did an hour ago — and the hour-ago version is sitting in storage, intact. Revisions are how you look at it. Revisions are **read-only**. Nothing here rolls a dataset back, and there is no "restore" button: you read the rows out of the last-good revision and write them in again through the normal write path, so the write carries provenance, honours the dataset's upsert key, and merges rather than clobbering whatever arrived since. ## What a revision is, and is not A revision exists for each version of a file dataset's stored contents. Alongside them you may also see a file that was **uploaded into** an existing dataset and merged into it — it is stored in its own right, so it is listed and queryable, but it was never the dataset's contents. That distinction is visible: a version that was live and has since been replaced reports **when** it was replaced; one that was never live reports nothing. Row-level writes — appending a row, updating one, deleting one — do not create a revision. They change the current contents in place. Revisions record whole-contents replacements, which is the failure they exist for. **Datasets queried live at their source keep no revisions here.** A dataset backed by a connected database or warehouse is read from that system on every query, so Erdo holds no copy to keep versions of, and its revision list is empty. ## Listing revisions ```bash theme={null} erdo datasets revisions acme.leads ``` The live version comes first, then superseded ones newest first. Each row carries the revision's **id** — the handle for reading it — when it was created, when it was superseded (blank on the live one, and on a version that was never live), the stored file's name, and its type. `--json` prints the raw result instead of a table. ## Reading one Pass a revision id to the normal read. The same DuckDB SQL you would run against the live table runs against that version's contents, with the file exposed as a table named `data`: ```bash theme={null} erdo datasets fetch acme.leads --revision \ --sql "SELECT * FROM data" ``` So the question "which rows did the bad refresh drop?" is a query, not an archaeology project: ```bash theme={null} # How many rows did the previous version hold? erdo datasets fetch acme.leads --revision --sql "SELECT count(*) FROM data" # The rows that version had and the live one does not, by email erdo datasets fetch acme.leads --revision \ --sql "SELECT * FROM data WHERE email NOT IN ('a@example.com','b@example.com')" ``` Reading a revision does not change the dataset. To put the rows back, take what the query returned and write it in the ordinary way — `erdo_write_rows`, `POST /v1/datasets/:datasetSlug/rows`, or just asking an agent — so the rows land with their provenance and the dataset's key decides what is an insert and what is an update. ## In chat An agent working on a dataset can do all of this itself. Ask it what a dataset held before a refresh, or to recover rows a rebuild dropped, and it will list the revisions, query the last-good one, and write the missing rows back. | Tool | Description | | ------------------------ | ---------------------------------------------------------------- | | `list_dataset_revisions` | List a dataset's stored versions, live first then newest first. | | `query_dataset_revision` | Run SQL against one revision's contents, the table named `data`. | ## MCP tools | Tool | Description | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `erdo_list_dataset_revisions` | List a dataset's stored revisions (`dataset_slug`). Returns each revision's id, created and superseded timestamps, whether it is the live one, and the stored file's name and type. | | `erdo_fetch_dataset_contents` | Read rows; pass `revision_id` to read that stored version instead of the live contents. | ## REST | MCP tool | REST endpoint | Method | | ----------------------------- | ------------------------------------- | ------------------------- | | `erdo_list_dataset_revisions` | `/v1/datasets/:datasetSlug/revisions` | GET | | `erdo_fetch_dataset_contents` | `/v1/datasets/:datasetSlug/fetch` | POST (send `revision_id`) | ```bash theme={null} # List the stored versions curl "https://api.erdo.ai/v1/datasets/leads/revisions" \ -H "Authorization: Bearer YOUR_API_KEY" # Query one of them curl -X POST https://api.erdo.ai/v1/datasets/leads/fetch \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "revision_id": "REVISION_ID", "sql_query": "SELECT * FROM data", "limit": 1000 }' ``` Both reads need the same view access as reading the dataset itself — a revision is that dataset's data, gated the same way. A [scoped key](/api/scoped-keys) with the `datasets:query` capability for the dataset can make both calls. A revision id that does not belong to the dataset you name is rejected as not found, so an id alone never reaches another dataset's contents. # Row actions Source: https://docs.erdo.ai/dataset-row-actions Declare what happens when new rows land in a dataset — invoke an action on each one, and record what came back. Data arriving is usually the start of something, not the end of it. A lead lands and somebody should hear about it. A lead lands and you want to know who they are before you call back. A support ticket lands and it should be filed somewhere else. All three are the same shape: **when rows land in this dataset, do this to each one**. A **row action** declares exactly that. You name the dataset, an ordered list of steps — actions to invoke, or code to run — and, optionally, where to record what came back. Erdo owns everything underneath: the automation, its trigger, the watermark separating new rows from old ones, and the bookkeeping that stops a burst being processed twice. There is no script to write and nothing to keep in sync. ## Why it attaches to a dataset Row actions hang off the **dataset**, not off the pipeline that fills it, and that distinction matters more than it first appears. A real development runs many lead-capture pipelines — one per landing page, per language, per variant. One customer has 25 of them, all writing a single leads dataset. Anything attached to a pipeline covers that pipeline alone, so the day someone launches landing page 26, its rows stop being acted on and nothing looks broken. Attaching to the dataset covers every writer, including the ones that don't exist yet. ## Declaring one `PUT /v1/dataset-row-actions` takes the dataset, a name for this declaration, and the steps. The declaration **replaces** whatever was there under the same name. ```bash theme={null} curl -X PUT "https://api.erdo.ai/v1/dataset-row-actions" \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "dataset": "2200-brickell.2200-brickell-leads", "name": "enrich", "steps": [ { "app": "apollo", "key": "enrich_person", "input": { "email": "{{email}}" }, "found_when": "found" }, { "app": "erdo", "key": "web_search", "input": { "query": "{{name}}", "num_results": 3 }, "result_path": "results.0", "fallback": true } ], "destination": { "dataset": "2200-brickell.lead-enrichment", "key_column": "lead_email", "columns": { "lead_email": "{{email}}", "lead_name": "{{name}}", "title": "{{result.title}}", "employer": "{{result.organization.name}}", "searched_at": "{{now}}" } } }' ``` `GET /v1/dataset-row-actions?dataset=` lists everything declared on a dataset; add `&name=` to read one. That state is read from the automations themselves, so there is no second copy of the declaration to drift out of sync with reality. Passing `"enabled": false` **removes** a declaration. By default a row action never works through a backlog. Where the dataset stands is recorded the moment you declare it, so only rows arriving afterwards are acted on — including the very first one. Removing one deletes the automation rather than pausing it, for the same reason: switching back on months later must not suddenly invoke a paid action for everything that accumulated in between. Ask for a [backfill](#applying-one-to-the-rows-already-there) when you want the history done. ## Applying one to the rows already there Not acting on a dataset's history is the right default — declaring an action must not quietly spend a paid lookup on every row already in the table. But it makes a declaration that was **wrong** permanent: every row it decided about is remembered as decided, so fixing the steps helps the next row and none of the ones already handled. `backfill` is how you say "and do the ones you got wrong". ```json theme={null} { "dataset": "acme.leads", "name": "enrich", "steps": [ ... ], "backfill": "all" } ``` `"all"` takes every row in the dataset. An RFC 3339 timestamp — `"2026-07-01T00:00:00Z"` — takes every row that arrived at or after that moment, including one stamped exactly at it. Omit the field and nothing historical is touched. A backfill re-invokes the steps for each row in range, **including rows this declaration has already acted on**, because reconsidering them is the entire request. Every action they invoke is paid for again. It does not all happen in one run. A run acts on a bounded number of rows and makes a bounded number of invocations, which is what stops a backfill becoming hundreds of external calls at once — so a large one is worked through over consecutive runs, each picking up where the last got to, until the declaration is level with the dataset and goes back to acting on what arrives. You do not have to do anything to keep it moving. The run summaries say how many rows are left. Editing a declaration is not a backfill. Changing a step rewrites the automation and leaves its position alone, so an ordinary edit never replays the dataset. ## Several per dataset The **name** is what lets one dataset carry more than one declaration. A leads dataset can alert the sales desk *and* enrich each record, as two independent declarations that neither replace nor interfere with each other. The name is also the handle you use to change or remove one later, and it is how each appears in your automation list. ## Steps run in order Each new row is taken through the steps **in order**, and each step sees what the ones before it produced. That is what lets a declaration look a lead up and then email the desk about them, with the email saying what the lookup found. A step is **either** an action to invoke or a **script** to run — never both. ### Alternatives, with `fallback` Mark a step `"fallback": true` and it runs only when nothing has answered yet. That is how "look this person up in the contact database, and web-search them when it has never heard of them" is expressed: the search costs nothing when the lookup already answered, because it never runs. Without the flag, steps are a sequence rather than a set of alternatives. Use the flag for the second way of getting the same answer; leave it off for the next thing to do with the answer you have. ### `found_when` — did it answer? An action that finds nothing rarely *returns* nothing. It returns an envelope saying so, and Erdo appends `ok: true` to every native action's result. A contact lookup that has never heard of an address replies: ```json theme={null} { "found": false, "email": "someone@gmail.com", "ok": true } ``` Left alone that reads as an answer, so the fallback behind it is unreachable and you pay the lookup on every row. `found_when` is a dotted path into the result that decides the question: `"found_when": "found"` reads the miss for what it is. This is deliberately separate from `result_path`, which decides what `{{result}}` **records**. They were once the same knob, and restoring the fall-through then meant pointing it at a scalar and throwing away the employer, title and profile URL you called the action for. ### `result_path` Many actions wrap their answer in an envelope that echoes the request. `result_path` selects the part worth recording — and becomes what `{{result}}` refers to, which usually makes the destination templates shorter too. When no `found_when` is given, it is also what decides whether the step answered. ### `for_each` — one action, several times Give a step a `for_each` list — a JSON array, or one `{{placeholder}}` resolving to one — and the action is invoked once per element, with `{{item}}` and `{{item_index}}` available in its input. Each invocation counts against the run's budget. ## Steps that are scripts Some decisions cannot be written as a template. Give a step a `script` instead of an `app` and `key`, and it runs as JavaScript against the row: ```js theme={null} // (row, result, steps, run) are in scope. var found = result || {}; var claimed = String(row.name || '').toLowerCase(); var got = String(found.name || '').toLowerCase(); var shared = claimed.split(/\s+/).some(function (w) { return w.length > 2 && got.indexOf(w) !== -1; }); if (!shared) { return { note: 'discarded ' + found.name + ': shares no name word with ' + row.name }; } var title = found.title, org = found.organization && found.organization.name; if (title && org) { return { summary: title + ' at ' + org, corroborated: 'name' }; } if (title) { return { summary: title, corroborated: 'name' }; } return { summary: org ? 'Works at ' + org : '', corroborated: 'name' }; ``` What it returns becomes that step's result. A script **always runs** — judging what the steps before it found costs nothing — and returning nothing **clears the answer**, so the next fallback is tried instead of a doubtful finding being recorded. That is what the example above is for. A contact lookup matches on the email address and returns whoever it has under it, with **no signal about whether that is the right person**: the confidence fields come back null on a good match and a wrong one alike, and the response shape is identical either way. Nothing in the payload can gate it. The only evidence available is what the lead themselves typed, and comparing the two is conditional work — share a name word or discard the finding, and compose the summary without a dangling connective when only one side is known. No template expresses that, and the alternative is hand-writing the whole automation. A script may also return a **note** explaining its decision. Notes appear on the run result and never count as an answer, so an explained rejection is not recorded as a match. A script shapes data. It cannot invoke actions or read datasets — those are the two things the declaration itself is supposed to state, so that what an automation touches can be read off it rather than found by reading code. ## Templates Any string in a step's `input`, and every value in `destination.columns`, may carry `{{placeholder}}` references. | What you write | What it resolves to | | ------------------------ | --------------------------------------------------------- | | `{{email}}` | the row's `email` column | | `{{result.title}}` | a dotted path into what the last step answered | | `{{result.hits.0.url}}` | the first element of a list | | `{{steps.0.name}}` | what a particular step returned | | `{{item}}` | the current element of a `for_each` list | | `{{now}}` | the current time, ISO 8601 | | `{{answered_by.action}}` | which step answered, as `app/key` — blank until one has | | `{{row.result}}` | the row's own column, when it is called `result` or `now` | `answered_by` is how a destination records **provenance** in a multi-step waterfall: a column mapped to `{{answered_by.action}}` says whether a row came from the contact database or the web search behind it, and stays blank on a lookup that found nothing. `answered_by.app`, `answered_by.key` and `answered_by.step` (the step's index) are also available separately. It names the action step whose answer `{{result}}` currently carries, so a script discarding a finding clears it, and a fallback that then answers becomes the provenance instead. The engine is deliberately small: dotted paths, and nothing else. **Anything it cannot resolve becomes blank** rather than an error, because a declaration written against one row shape will meet rows that lack a column — and one missing column must not stop the run for every other row. A value that is **exactly one placeholder** keeps the resolved type, so `"num_results": "{{result.count}}"` reaches the action as a number rather than the string `"3"`. Anything else is text substitution. Values that are not strings — `"num_results": 3` — are settings you wrote, and pass through untouched. ### Required and optional placeholders A step whose templated input resolves to nothing **cannot be built for that row**, so it is skipped and the next step is tried. That is how a declaration handles a row with no email at all, and it is usually what you want. Write `{{?path}}` when the value is decoration rather than a requirement: ```json theme={null} { "subject": "New lead: {{name}}", "body": "{{?result.summary}}" } ``` The step still runs when the optional value is missing. This matters more than it looks: an alert must never be **conditional** on an enrichment that happened to find nothing. The email was always going to be sent, and what was looked up only makes it better. ## Ordered steps, and what one row costs Actions cost money and scripts do not, which is the whole reason the two are treated differently. A fallback exists so you do not pay twice for the same answer; a script runs every time because it costs nothing to ask it. ## The destination Give a `destination` to record what came back. Omit it entirely for an action whose effect *is* the action — sending an email, filing a ticket. `key_column` makes the write an **upsert** on that column, so acting on the same subject twice replaces what was known rather than appending a rival copy of it. A row whose key renders empty is not written: there would be nothing to identify it by, and every such row would collide with every other one. The destination must be a **different dataset** from the source. Writing back into the source would trigger the automation again, and a keyed write would match each source row and overwrite it with a partial one — erasing the captured columns your result does not carry forward. A run that found nothing still writes its row. *"We looked and found nothing"* is a different and useful fact from *"we never looked"* — the first says this subject has nothing worth surfacing, the second says nothing has reached them yet — and a surface can only tell them apart if the empty result is recorded. ## A row written twice Datasets that receive leads are **upserted**, not appended: a landing page that captures an address first and the rest of the form a minute later writes the same row twice. Arrival time alone cannot tell that from a new row — the second write moves it forward — so a declaration would invoke its actions a second time for a subject it has already acted on, and pay for them a second time. The write settles it. Every row records the moment the dataset created it, in a column named `erdo_created_at`, and a second write to the same row leaves that moment alone however far the arrival column moves. A declaration keeps its position in that column as well as in the arrival column, so a row created no later than the point it has already dealt with is one it has already decided about. On top of that it remembers what it has acted on: the value in the row's identifying column, and a fingerprint of what the row said. That is what covers rows written before `erdo_created_at` existed, and it is what `act` below is decided from. A row recognised either way is left alone and counted under `repeats`. Erdo detects the identifying column the same way it detects the arrival column — by probing what the newest row actually carries, since a column can exist and be empty on nearly every row — and `key_column` overrides it. `on_update` decides what a second write means: | Value | What happens | | ---------------- | ------------------------------------------------------------------- | | `skip` (default) | A row already acted on is left alone. | | `act` | A row whose **content changed** is taken through the actions again. | Even under `act`, a rewrite that says exactly what the last one said is ignored: that is not new information, and a writer replaying a batch must not become a second bill. Choose `act` where a later write is what makes the actions able to answer at all — a form's first write may carry no name, which is exactly when a contact lookup has nothing to check its match against. A row with nothing identifying it is always acted on. Acting twice is bad; never acting is worse. ## What one run does A declaration may list up to **5 steps**. Each run acts on **at most 10 new rows** and invokes **at most 10 actions**, whichever binds first. That keeps an import from becoming hundreds of paid calls in one pass. The watermark still advances past everything that arrived, so a burst larger than the cap has its excess left rather than retried forever, and the run result reports the count under `not_processed` rather than claiming a clean success. A single failed step is ordinary — a rate limit, a transient upstream error — and never abandons the other rows in the same run. But a run in which **every** invocation failed is not one flaky call: it is a declaration that cannot work, so the run **fails**. That both makes it visible in your automation history and leaves the watermark where it was, so those rows are still waiting once you fix the cause. ## Which column marks arrival Erdo detects the column recording when a row arrived (`submitted_at`, `created_at`, `received_at`, and similar) by probing the dataset's real columns when you declare the row action. If your dataset names it something unconventional, pass `timestamp_column` explicitly. Detection happens at declaration time on purpose: a dataset with no usable column is an immediate error you can fix, rather than an automation that looks perfectly healthy and quietly never acts on anything. ## Cost Nothing per run. The automation is a deterministic script fired by an event trigger on the dataset — no LLM, and no polling loop burning tokens to ask whether anything changed. It runs when a row lands and is otherwise idle. The only thing that costs anything is the action itself. A [backfill](#applying-one-to-the-rows-already-there) is the exception worth knowing about, and only because of what the steps do: it invokes them once per row in range, so a metered lookup over a thousand historical rows is a thousand lookups. The runs themselves stay negligible. Don't build this with a recurring agent or a polling heartbeat. A five-minute heartbeat doing a job like this once cost a customer roughly \$81 in a single week to notice one or two rows a day — while also being up to five minutes slower than the event trigger. ## Actions that need approval Some actions require a standing approval before an unattended automation may invoke them. Grant one by running the action once in a thread and choosing **always allow**; without it, the invocation fails and — since that failure affects every row — the run goes red rather than passing quietly. ## Related The purpose-built version of "email these people when rows land". The run history of the automation behind a row action. Standing policies that let an automation invoke an action unattended. Where the rows land, and where results are written. # Decisions Source: https://docs.erdo.ai/decisions The record of what your organization committed to, whether the change actually happened, and what the evidence said afterwards — over MCP, REST, or the CLI # Decisions A **Workstream** remembers a great deal of context: what was discussed, what was tried, what somebody suggested. A **decision** is the narrower thing your organization actually committed to — a course, the reason it was chosen, who authorized it, and what it changed in the outside world. Approving a card records one. So does an agent committing to a strategy that changes nothing outside Erdo, such as which audience a campaign targets. The record exists to answer questions that used to have no data behind them. What did we decide about this campaign, and why? Did the change actually go through? Did leads or cost move afterwards, and how strong is that evidence? And the one every other question is really asking: can this be trusted to make these calls without me? A decision is not a log line. It is authoritative until an explicit later decision supersedes it, which is what lets an agent picking work up weeks later continue what you agreed rather than re-argue it — and what makes a change of course visible, because reversing a decision records a new one naming the decision it replaces. Decisions are part of the work engine and roll out with it. Until Workstreams are enabled for your workspace, these endpoints and tools return `permission_denied` ("workstreams are not enabled for this organization") — contact us to get switched on. ## What a decision carries * **The commitment** — `what` in one line of business English, and `why`: the argument the proposal was made on. When the person deciding gave their own reason for accepting, refusing or correcting it, that is kept separately as `decider_rationale`. The two are never merged: a proposal's argument read months later as the approver's reason for saying yes is a motive nobody stated. * **The class** — a stable, provider-agnostic name for the kind of decision this is (`paid_media.ad_group.pause`, `page.publish`, `experiment.variant.stop`). It is what the scorecard groups on, so it survives a tool being renamed or split. * **Applicability** — `standing` for a course that outlives the work it authorized, `one_shot` for an authorization covering exactly the actions it named. A page that published on Tuesday is not a course anybody continues; "target finance leaders on this campaign" is. * **The authority** — who exercised the judgement (`human`, `llm_agent`, `deterministic_policy`, `safe_default`, `system`) and, where relevant, which person and which agent run. * **The subject** — the campaign, page, or lead form the decision is about, so the record is searchable by the thing that changed. * **The ask it answers** — for a decision that came from an approval, the `approval_id` of the card somebody answered. It is on the row and it is also a filter, so you can go from an approval straight to what answering it produced, whether the answer was yes or no. One approval can name several decisions: a batch is split into one decision per coherent intent once it is answered. * **Actions** — one row per exact external call the decision authorized, each with its own subject and its own execution result. A batch that pauses four ad groups keeps four results, so a partial success stays visible instead of collapsing into one flag. * **Expected effects** — what the decision said, in advance, it would move: the metric, the predicate that counts as success, the windows to judge it over, and any guardrails. Most decisions declare none, and that is the honest answer rather than a gap — a permission fix has no business effect anything could measure. ## The lifecycle ```text theme={null} proposed → authorized → executing → effective → measuring → settled proposed → rejected authorized | executing → failed effective | measuring → censored ``` `effective` means the change is live in the outside world. `measuring` means its declared expectation is being watched. `settled` means the evidence came in. `censored` means something intervened — usually a later decision replacing this one mid-window — so there is no honest before-and-after left to compute; the decision still stands, what was lost is the ability to score it. An action Erdo could not confirm ends `unknown` rather than succeeded or failed. That state is kept deliberately: the change may be live and nothing verified it, and it is resolved by reading the provider back, never by trying again. ## How an outcome is described Every settled outcome carries the **kind of evidence** that produced it, and the words Erdo uses depend on it. This is not a stylistic preference — a before-and-after movement presented as proof the change caused it is the most damaging thing this record could produce, so the vocabulary is fixed to what the evidence can support: | Evidence | What it establishes | Met | Not met | | --------------- | ------------------------------------------------------------------------------------------------------------ | ----------------- | ------------------------ | | `deterministic` | The specified state exists — the page is live, the campaign is paused. It says nothing about business value. | confirmed | not confirmed | | `experimental` | A controlled comparison against a concurrent arm. | worked | did not work | | `observational` | A before-and-after movement with no control. | moved as expected | did not move as expected | An uncertain or stale result is `inconclusive` under every kind, with a reason recorded — a result nobody can explain is a result nobody can act on. Erdo sends the phrase itself as `outcome_label` on every read, so each surface says the same thing about the same row rather than deriving its own wording. ## The scorecard `decisions scorecard` reports **raw counts with their denominators**. It deliberately publishes no eligibility verdict and no single pooled "worked rate": * **Totals** — every decision in the window, split by status and by producer family, with the backfilled legacy history named separately so the numbers reconcile. * **Execution** — how many decisions authorized an external call at all, how many actions ran, and how each ended, with the unconfirmed ones counted rather than absorbed into a success rate. * **Measurement coverage** — how many decisions declared an expectation, how many effects exist, and where each one sits: settled, censored, or waiting on a measurement Erdo cannot yet read. A met rate over six settled effects out of two hundred decisions is a different claim from the same rate over a hundred and eighty, so the denominator travels with it. * **Outcomes, stratified** — the settled split cut by decision class and evidence kind, and again by producer family and evidence kind. Never pooled: averaging a deterministic confirmation against an observational movement produces a number that is wrong in whichever vocabulary you read it in. * **Deciders** — who exercised the judgement, how often the safe default applied because nobody answered, and how often a person refused something the system proposed. * **Time to authorize** — the median and 90th percentile wait between a decision being proposed and being authorized. Nothing in this readout changes your autonomy settings. Your configuration stays the ceiling; the scorecard is evidence you read, never something that grants Erdo more authority. ## CLI ```bash theme={null} # The whole record, newest first erdo decisions list erdo decisions list --json # Narrow it — filters compose erdo decisions list --workstream brickell-lead-strategy erdo decisions list --class paid_media.ad_group.pause --status settled erdo decisions list --subject-kind campaign --subject-ref 24033607833 erdo decisions list --applicability standing # the courses currently in force erdo decisions list --outcome not_met # what did not do what it promised erdo decisions list --approval # what answering that card produced # One decision in full erdo decisions show erdo decisions show --json # The raw scorecard erdo decisions scorecard erdo decisions scorecard --workstream brickell-lead-strategy erdo decisions scorecard --since 2026-07-01T00:00:00Z --until 2026-08-01T00:00:00Z erdo decisions scorecard --json ``` `erdo decisions list` prints one row per decision: the slug, its status, its execution result, what it was, and — only once something settled — how it turned out, in the words its evidence supports. `erdo decisions show` prints the drill-in in the order the record means something: the commitment and its authority, then what was actually attempted, then what was expected and what the evidence said, then what this decision replaced or was replaced by. Decisions are referenced by **slug**, never UUID. Commands act on your active organization; pass `--org ` to pin a different one. ## MCP tools | Tool | Description | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `erdo_list_decisions` | Search the record. Filter by `workstream_slug`, `source`, `decision_class`, `subject_kind` + `subject_ref`, `status`, `applicability`, `outcome`, or `approval_id` — the ask that was answered. | | `erdo_get_decision` | Read one decision in full by `decision_slug`: the commitment and its rationale, who decided it and under what authority, every exact action with how it ended, every declared effect with the evidence that settled it, and supersession in both directions. | | `erdo_decision_scorecard` | Raw aggregates with denominators, stratified by decision class and evidence kind. Optional `workstream_slug`, `source`, `decision_class`, `since`, `until`. | Identity and access ride the request context, so a caller only ever reaches their own organization's record — and inside it, only the projects they can view. A decision filed under a workstream inherits that workstream's project access, so a teammate who was never given a restricted project sees none of its decisions in the search, cannot open one by slug, and does not have its rows counted into the scorecard's totals. Decisions that belong to no workstream — a page publish, an email send — are organization-level and stay visible to everyone. ## REST | MCP Tool | REST Endpoint | Method | | ------------------------- | ------------------------- | ------ | | `erdo_list_decisions` | `/v1/decisions` | GET | | `erdo_get_decision` | `/v1/decisions/:slug` | GET | | `erdo_decision_scorecard` | `/v1/decisions-scorecard` | GET | Base URL `https://api.erdo.ai`. Authenticate with `Authorization: Bearer ` and select the org with `X-Organization-ID`. ```bash theme={null} # Everything decided about one campaign curl "https://api.erdo.ai/v1/decisions?subject_kind=campaign&subject_ref=24033607833" \ -H "Authorization: Bearer YOUR_API_KEY" # What answering one approval produced curl "https://api.erdo.ai/v1/decisions?approval_id=" \ -H "Authorization: Bearer YOUR_API_KEY" # One decision in full curl https://api.erdo.ai/v1/decisions/ \ -H "Authorization: Bearer YOUR_API_KEY" # The scorecard for a period curl "https://api.erdo.ai/v1/decisions-scorecard?since=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer YOUR_API_KEY" ``` `GET /v1/decisions` pages with `limit` (default 50, max 200) and `offset`, newest first. An unrecognised value in a closed vocabulary — a misspelled `status`, a `source` that does not exist — is refused rather than ignored, because silently dropping the filter would return the whole organization to a caller who asked for one slice of it. `approval_id` is refused the same way when it is not a UUID, and it narrows within your access rather than around it: an approval answered inside a project you cannot view matches nothing. `GET /v1/decisions/:slug` returns the decision, its actions, its effects, and its lineage. A slug belonging to another organization comes back `404` rather than `403`, so a slug can never be probed for existence. `GET /v1/decisions-scorecard` takes `since` and `until` as RFC3339 timestamps (`since` inclusive, `until` exclusive) and echoes the window back on the response, so a readout can never be quoted without the period it covers. # Evals Source: https://docs.erdo.ai/evals Run and maintain evaluations of agents and generated pages — over MCP, REST, or the CLI # Evals Erdo evals score an agent's work against a rubric, with pass/score tracking over time. They run in production and are designed to be driven by an AI coding agent (e.g. Claude Code via MCP): change how something is generated, run the suite, read the per-criterion scores, then add, rewrite, or remove cases as the generation changes. Two kinds of suite: * **Text suites** — judge the agent's text answer against the rubric (e.g. the data-question-answerer). * **Artifact suites** (`evaluate_artifact: true`) — judge the **rendered page** the agent builds (landing pages, dashboards, apps). Erdo publishes the page, screenshots it on desktop and mobile, drives it in a real browser (submits the lead form, advances the carousel, confirms the voice widget loads, checks the conversion analytics event), and scores the screenshots + interaction with a vision model. A page that looks fine but doesn't capture the lead scores low. Suites are referenced by **slug**. Each suite has cases (`name`, `input` brief, `rubric` of weighted criteria), a judge model, and a `pass_threshold` (0–5). Cases run through the **same path a real user/API call hits** — a thread, a message, and the normal agent invocation — so an eval tests the actual product flow, not a synthetic one. (External egress like email/integration writes is mocked during an eval run; everything else is real.) A case may add `setup_messages`: ordered turns run **before** `input` in the same thread, for multi-step flows — e.g. *create a voice widget*, then *build the landing page wired to it*. The judge scores the result of `input`. ### Evaluators A case can specify **evaluators** with two distinct roles, so an eval discriminates instead of passing everything: * **`llm_rubric` = the score.** An LLM scores the output (or rendered page) against the rubric — the real signal; the case score is its weighted verdict. * **`script` = a gate, not a scorer.** A JS `evaluate(ctx)` returning `{score, passed, reasoning}`, run deterministically (**no LLM tokens**). `ctx = {input, output, artifact, data_store}`. Use it for structural prerequisites (the lead form exists, the test lead actually landed in `data_store`). A clean gate adds **no points**; a **failed** gate hard-fails the case (structure missing = broken). The case score comes from the `llm_rubric` (a failed gate forces it to 0); a script-only case scores on the gate. **Any evaluator that errors fails the case** — never a silent pass. Set evaluators via `--evaluator` (CLI) or the `evaluators` field (MCP); omit to use the rubric as a single `llm_rubric`. ## CLI ```bash theme={null} npm i -g @erdoai/cli erdo auth login # paste an API key (multi-account, like gh) erdo org list # your orgs (* = active) erdo org use acme # set the active org (evals are org-scoped) erdo eval suites # list suites erdo eval create landing-variations --agent erdo.artifact-builder \ --evaluate-artifact --no-cron \ --case '{"name":"voice","input":"{\"artifact_kind\":\"landing_page\",\"description\":\"...\"}","rubric":[{"criterion":"voice widget loads","weight":2}]}' erdo eval suite landing-variations # show a suite + cases erdo eval run landing-variations --watch # run and poll to completion (CI-friendly: non-zero on failure) erdo eval results # per-case scores + lenses erdo eval runs --suite landing-variations # recent runs erdo eval case add landing-variations \ --name voice-concierge \ --input '{"artifact_kind":"landing_page","description":"Landing page for ACME with a voice concierge ..."}' \ --rubric '[{"criterion":"voice widget loads and is on-brand","weight":2},{"criterion":"hero + form render","weight":1}]' erdo eval case rm landing-variations voice-concierge ``` For artifact suites the case `input` is the builder's JSON input — `artifact_kind` (`landing_page` | `dashboard` | `app`) plus a `description`. An artifact suite builds real pages in whatever org the command resolves to. Once every case is judged, the run **cleans up the pages it built** — they are soft- deleted, so they stop appearing in listings and their public links stop working. This keeps a later run of the same brand from discovering a leftover and editing it instead of building fresh (which scores it zero). The cleaned-up pages stay referenced from the run results and are restorable (`erdo pages restore `) if you want to inspect or keep one. The active org set by `erdo org use` is machine-global and a concurrent session can switch it, so `erdo eval run` refuses an artifact-building suite unless you pin the org explicitly — pass `--org ` (or set `ERDO_ORG`): ```bash theme={null} erdo --org acme eval run landing-variations --watch ``` Text-only suites don't build artifacts and run without a pin, though pinning is still the safer habit for any scripted run. See [pinning the org in automation](/cli#pin-the-org-in-automation). ## MCP tools Available on the [MCP server](/mcp/overview); identity and permissions ride your token, so you only see your organization's and global suites. | Tool | Purpose | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | `erdo_list_eval_suites` | List suites (slug, agent, settings) | | `erdo_get_eval_suite` | A suite with all its cases | | `erdo_create_eval_suite` | Create a suite + first cases (set `evaluate_artifact` for visual suites) | | `erdo_add_eval_case` / `erdo_update_eval_case` / `erdo_remove_eval_case` | Maintain the case corpus | | `erdo_run_eval_suite` | Run a suite, returns `run_id` | | `erdo_get_eval_run` | Run status + per-case scores, per-criterion breakdown, judge reasoning, artifact links | | `erdo_list_eval_runs` | Recent runs with aggregate pass counts/scores | ## REST Base URL `https://api.erdo.ai`, `Authorization: Bearer YOUR_API_KEY`. | Method | Path | | ------ | ----------------------------------------------- | | GET | `/v1/evals/suites` | | GET | `/v1/evals/suites/{suiteSlug}` | | POST | `/v1/evals/suites` | | POST | `/v1/evals/suites/{suiteSlug}/cases` | | PUT | `/v1/evals/suites/{suiteSlug}/cases/{caseName}` | | DELETE | `/v1/evals/suites/{suiteSlug}/cases/{caseName}` | | POST | `/v1/evals/suites/{suiteSlug}/run` | | GET | `/v1/evals/runs/{runID}` | | GET | `/v1/evals/runs?suite_slug={slug}&limit={n}` | Artifact suites are expensive (they build + render + score real pages), so they're excluded from the daily cron (`cron_enabled: false`) and run on demand. # Event pipelines Source: https://docs.erdo.ai/event-pipelines See and inspect the deterministic flows that turn inbound events — lead-form submissions, third-party webhooks — into enriched rows, emails, and integration actions. # Event pipelines An **event pipeline** is a deterministic flow that receives an inbound event and does fixed work with it: a lead-form submission, a third-party webhook, or an in-app action arrives at the pipeline's endpoint, gets normalized, and runs through an ordered list of **actions** — write the row to a [dataset](/data), send an email, call a connected integration, broadcast a live update, or return a custom response. Pipelines are how a [page](/pages) captures leads, and how Erdo enriches and routes them without an agent in the loop on every event. Each pipeline has: * a **slug** and **name** — a stable, human-readable handle (e.g. `acme-leads-a1b2c3d4`), * a **state** — `active`, `disabled`, or `proposed`, * a **source kind** — `browser_form`, `third_party_webhook`, `custom_http`, `internal`, …, * an **inbound endpoint URL** — where events are POSTed, * an **auth mode** — how inbound events are verified (HMAC, shared secret, provider signature, signed-in viewer, or public), * the **actions** it runs and the **datasets it writes to**, and * an **execution log** — every event it received, with status (`accepted` / `rejected` / `failed`), a payload preview, the response, and any error. ## Where data lands A pipeline's `dataset.write` actions persist rows to a **dataset** — per-row, SQL-queryable storage. To see captured leads, query the dataset the pipeline writes to (shown as `write_target_datasets`), not the pipeline itself. The pipeline's **execution log** tells you whether events are arriving and being accepted; the **dataset** holds the rows. ## Emails a pipeline sends A pipeline that emails on an event — a lead alert to your sales inbox, a confirmation back to the person who submitted the form — has Erdo compose the message. The pipeline supplies the content (a heading, the captured fields as label/value rows, a button through to the page the lead came from) and Erdo renders it in the standard Erdo styling, with a plain-text version alongside for clients that block HTML. Addresses and links in the fields become clickable, so a lead alert can be replied to from the notification itself. ## Pipeline purpose Every pipeline declares a **`purpose`** — its role — so you can tell capture flows apart from analytics infrastructure without guessing from names: * **`record_capture`** — a pipeline whose job is persisting submitted records to a dataset (it has at least one `dataset.write` action). This is how you find which datasets receive a flow's records: filter pipelines by `purpose = record_capture` and read their `write_target_datasets` (each entry is a fully-qualified `org-slug.dataset-slug`). You can set this explicitly when creating or updating a pipeline; a `custom` pipeline that writes to a dataset is also promoted to `record_capture` automatically at save time. * **`page_events`** — the managed first-party page-analytics pipeline described below. Reserved for Erdo's own provisioning; you can't set it. * **`custom`** — everything else: a webhook or enrichment flow that doesn't persist records. To discover which dataset a campaign's leads land in, list your `record_capture` pipelines and read their `write_target_datasets`, then query that dataset. Every `dataset.write` row is stamped at ingestion with columns your transform doesn't set: `variant`, `artifact_id`, `page_url` (from the page envelope), plus a set of request signals that let you keep your metrics clean and segment them by who actually showed up: * **`is_test`** — a boolean, always present. `true` when the submission was tagged as test traffic (see [Marking test traffic](#marking-test-traffic) below), otherwise `false`. Filter it out to count real leads and conversions. * **`device_type`** — `"mobile"`, `"tablet"`, `"desktop"`, or empty, classified from the request's `User-Agent` at ingestion. Segment funnels by it to compare mobile vs desktop (a page can take most of your spend yet convert zero mobile visitors — this column surfaces that without parsing User-Agents by hand). * **`browser`** — the coarse browser family (`"chrome"`, `"safari"`, `"firefox"`, `"edge"`, `"samsung"`, `"opera"`, `"other"`, or empty), also from the `User-Agent`. * **`in_app`** — the in-app webview container the visit came from (`"instagram"`, `"facebook"`, `"tiktok"`, and similar), or empty for a normal standalone browser. In-app webviews routinely break modals and payment flows, so this separates "converts badly on mobile" from "converts badly inside Instagram's browser". * **`language`** — the visitor's preferred language, the primary tag of the `Accept-Language` header (e.g. `"pt-BR"`), or empty. Check that a Brazilian, Colombian, or French campaign is actually reaching PT/ES/FR speakers. * **`country`**, **`region`**, **`city`** — the visitor's coarse location: ISO-2 country (e.g. `"US"`), ISO-3166-2 region code (e.g. `"NY"`), and city name, from the serving edge. Empty when the request reaches the pipeline through an ingress that carries no edge geo. Segment by them to verify geo-targeted campaigns (e.g. "New Yorkers relocating to Miami") landed where you paid for. These are stamped only where your transform left the column unset, so a page that maps its own column of the same name always wins. Each is **epoch-bound** — rows written before the column existed carry it blank, which means "no signal", not a real value like desktop or zero visitors. ## Marking test traffic QA checks and automated evaluations submit to the same pipelines your real visitors do, so their rows would otherwise inflate lead and funnel counts. Tag a submission as test traffic and it lands with `is_test = true`, keeping it out of your real-traffic metrics. Two equivalent markers: * Send the request header **`X-Erdo-Test: 1`** (`1` or `true`), or * Include a top-level **`"is_test": true`** field in the JSON body. The envelope's marker always wins over the transform, so a transform can never clear a test mark. The marker is trusted as-is: mismarking real traffic as test only hides it from your own dashboards, so there's no signature to manage. Auto-provisioned **page-events** datasets ship with a default filter that hides `is_test = true` rows, so their funnel counts exclude test traffic automatically; add the same `is_test` `not_equals` `true` default filter to your own lead datasets to get the same behaviour on reads. ## Auto-provisioned page-events pipelines Alongside the pipelines you or your agents create, every published page gets one **page-events pipeline** automatically (its `purpose` is `page_events` — see [Pipeline purpose](#pipeline-purpose) above). It receives the page's first-party analytics events — pageviews, scroll milestones, section views, CTA clicks, form starts, leads, video plays — and writes them to your organization's shared **Page events** dataset, stamped with the page, experiment variant, session, campaign attribution, `is_test`, `device_type`, `browser`, `in_app`, `language`, and geo (`country`/`region`/`city`). Events with names outside that fixed vocabulary are rejected, which you can see in the pipeline's execution log. Disabling a page-events pipeline turns first-party analytics off for that page; Erdo won't re-create it. See [Pages → First-party page events](/pages#first-party-page-events) for what's captured and how to query it. ## Managing your pipelines You can list your pipelines, read one's configuration, review its run history, **create** new pipelines, **update** their editable fields, and **rotate** their verification secrets over **MCP** and the **REST API**; the **CLI** covers the read side (list, get, executions). Everything is **org-scoped** — a caller only ever sees their own organization's pipelines — and pipelines are referenced by **slug** (a UUID is also accepted). ### CLI ```bash theme={null} # list pipelines in the active org erdo event-pipelines list # inspect one — state, source, endpoint URL, actions, write targets erdo event-pipelines get acme-leads-a1b2c3d4 # review run history (newest first) — is it firing? are events accepted? erdo event-pipelines executions acme-leads-a1b2c3d4 --limit 50 ``` The CLI authenticates with your Erdo token and the active organization (`erdo login`, `erdo org`). ### MCP tools | Tool | What it does | | ------------------------------------- | ---------------------------------------------------------------------------------------------- | | `erdo_list_event_pipelines` | List your org's event pipelines — slug, name, state, source, endpoint URL, write targets. | | `erdo_get_event_pipeline` | One pipeline by slug — full config: auth mode, transform, actions, datasets it writes to. | | `erdo_list_event_pipeline_executions` | Recent run history (newest first) — per-event status, payload preview, response, error. | | `erdo_create_event_pipeline` | Create a pipeline — name, source, auth, transform, actions. Returns the generated secret once. | | `erdo_update_event_pipeline` | Edit a pipeline's mutable fields — omitted fields keep their current values. | | `erdo_rotate_event_pipeline_secret` | Mint a fresh verification secret; returned once, old secret stops verifying immediately. | Identity and RBAC ride the request context, so a caller can only reach their own org's pipelines. ### REST Base URL `https://api.erdo.ai`. Authenticate with `Authorization: Bearer ` and select the org with `X-Organization-ID`. | Method | Path | What it does | | ------ | ----------------------------------------- | ---------------------------------------------------------- | | `GET` | `/v1/event-pipelines` | List your org's pipelines. | | `GET` | `/v1/event-pipelines/:slug` | One pipeline's full configuration. | | `GET` | `/v1/event-pipelines/:slug/executions` | Recent run history, newest first. | | `POST` | `/v1/event-pipelines` | Create a pipeline. | | `PUT` | `/v1/event-pipelines/:slug` | Update a pipeline's editable fields (merge-over-existing). | | `POST` | `/v1/event-pipelines/:slug/rotate-secret` | Rotate the inbound verification secret. | To confirm a pipeline is capturing events end-to-end, check its executions here, then query the dataset it writes to. ### Creating a pipeline `POST /v1/event-pipelines` (or `erdo_create_event_pipeline`) takes: * **`name`** (required) — the slug is derived from it, * **`state`** — `active`, `disabled`, or `proposed` (the default); pass `active` to start capturing immediately, * **`source_kind`** — `browser_form`, `third_party_webhook`, `custom_http` (default), …, * **`auth_mode`** and **`auth_config`** — how inbound events are verified. `hmac_sha256` (the default), `shared_secret_header`, and `provider_signature` carry a verification secret: omit it and one is generated for you. `authenticated_viewer` accepts signed-in page viewers — set `auth_config.audience` to `"public_and_viewers"` for a public lead form, * **`request_schema`** — a JSON Schema the inbound payload must satisfy, * **`transform_js`** — `function transform(event) { … }` normalizing the payload before the actions run, * **`pipeline`** — the ordered actions array, e.g. `[{"type": "dataset.write", "dataset_slug": "riverview-leads"}]`. Reference datasets by **slug**, never UUID — a plain slug targets your own organization's datasets, and a well-formed slug that doesn't exist yet is **auto-created** as the pipeline's write target (reported back in `created_datasets`), so provisioning a new campaign's lead capture is one call, * **`purpose`** — `custom` or `record_capture` (see [Pipeline purpose](#pipeline-purpose)); `page_events` is reserved, * **`owner_artifact_id`** — the page this pipeline belongs to, for page-fed pipelines. The inbound **source and auth are set at creation** and are not editable afterwards; everything else can change via `PUT /v1/event-pipelines/:slug`. ### Secrets are shown once For the secret-bearing auth modes, the create and rotate responses carry the plaintext verification secret in a **`secret`** field — **exactly once**. List and get never show it again (the stored `auth_config` is redacted on every read), so store it when you receive it. Rotating (`POST /v1/event-pipelines/:slug/rotate-secret`) mints a fresh secret and invalidates the old one immediately — update the sender's signing configuration right away. Pipelines whose auth mode carries no secret (`authenticated_viewer`, `public_artifact`, `none`) reject rotation. # Experiments Source: https://docs.erdo.ai/experiments Run structured tests — hypothesis, variants, decision rule — and decide whether a change worked. # Experiments An **Experiment** is a structured test: a hypothesis, one or more **variants**, a **decision rule**, a **primary metric**, and the **evidence datasets** that measure it. It's the "decide" side of an Erdo loop — a [Workstream](/workstreams) does the work, an Experiment decides whether a change worked. An experiment can be hosted by a workstream or stand alone. The day-to-day measurement is deterministic: a recipe reads metrics and appends **observations** (metric reads, decision checks, actions) to an append-only log — no agent per tick. You read that log to see whether the loop is working, then record the **decision** (ship / stop / iterate / inconclusive). In your workspace, Experiments live under **Activity**, usually inside the [Workstream](/workstreams) that hosts the work. An agent sets one up with a hypothesis and a decision rule, the measurement loop appends observations over time, and you read the evidence and record the call. They can also stand alone when you just want to test one thing. ## Lifecycle `planned` → `running` → `reading` → `decided`. When you decide, set `status=decided`, a `decision` (`ship`/`stop`/`iterate`/`inconclusive`), and an `outcome_markdown` narrative together. A new experiment starts in `planned` and is **inert** — it measures nothing until you **arm the measurement loop**. Arming has two steps: configure a scheduled measurement recipe (a `script_js` dataset refresh that calls `experiment.recordObservation` per variant, with `experiment_run_id` in its parameters), then flip it to `running`. An agent that creates an experiment does both in the same turn; if you create one via the API/CLI directly, do the same so it doesn't sit silently recording nothing. Measurement needs each variant to be identifiable in the evidence data (e.g. one ad group per variant, or a landing-page path) so observations can be attributed. You don't write a measurement recipe per experiment. A **generic, config-driven recipe** (`recipe-experiment-measure`) is the default: each variant declares how to filter its own rows per dataset (`{ column, values }`) and each metric declares the SQL that computes it (returning a `value` column, with `{{variant_filter}}` and `{{window}}` placeholders). The recipe loops metrics × variants and records the observations — no per-use-case code. ## Observation types — measurements, forecasts, and orderings The observation log holds more than metric reads. Three types matter when you're reading whether the loop is working: * **`metric_read`** — what reality measured for a variant (cost per lead, conversion rate). This is the ground truth every other type is scored against. * **`prediction`** — a *forecast* a judge made before reality spoke: "I expect variant B to convert better." A judge is any cheaper-than-reality opinion — a critic lens, a persona panel, a pairwise tournament, or a human's answer to a [choice](/attention). A prediction is only worth something once a later `metric_read` on the same variant lets you check it, so predictions are stored so they can be *paired* with the measurement that eventually arrives. * **`comparison`** — an *ordering* between variants rather than a value: "B beat control." Two predictions imply an ordering, an explicit head-to-head records one directly, and a human [choice](/attention) tied to a running experiment writes one every time you pick — your pick marks the chosen variant as beating the ones you didn't. You read them the same way as any observation — `erdo experiment observations --type prediction` (or `comparison`) — and they carry the same append-only, RBAC'd guarantees as metric reads. ## Judges and the calibration record Predictions and comparisons are only useful if you find out how often they were *right*. That's what **calibration** is: pairing each judge's forecasts and orderings against the outcomes reality later measured, so a judge earns trust from its track record instead of assertion. The headline signal is **pairwise agreement** — of the variant pairs where both the judge and reality expressed an ordering, how often the judge ordered them the way reality did. It covers prediction rows (two forecasts imply an ordering) and comparison rows (an explicit ordering) uniformly, against reality's ordering from the measured metric reads. Alongside it you get raw prediction and comparison counts, how many predictions reality has caught up with (*paired predictions*), and — because a human choice is just another judge — the same reading for your own answers. The calibration readout is the trust dial: it's a **query over the ledger**, not a separate subsystem, so it stays honest as more observations land. A [landing-page A/B test](/ab-testing-pages) is the fullest example: the automatic critics predict which page will convert, your [attention-feed choices](/attention) record human orderings, and the lead dataset supplies the `metric_read` that scores both — so over time you learn which judge to trust before you've spent the traffic to be sure. **Default judges are platform-calibrated, so they aren't blind on day one.** A new workspace has no paired outcomes for weeks, which would leave the default lenses Erdo ships with no track record. For those default lenses only, the calibration prior pools *statistics* across every workspace — agreement rates and sample counts, never anyone's page content, verdicts, or identity — so a default judge starts from the whole platform's record. That prior is then adjusted by your own paired outcomes as they accumulate: with enough of your own results a judge whose reality diverges from the platform's overrides the shared prior. Judges you author stay private to your workspace and never pool. While a judge's local record is still thin, Erdo keeps a slightly larger share of bets on the random arm, so your own traffic confirms or refutes the shared prior faster. ## The judge profile page Every judge has a profile — open one from the **Judges** tab under **Activity**, or from an item's [actor chip](/attention). A judge is a **review lens** (a critic skill): its profile shows the **rubric** it grades against, the artifact **kind** it screens, and whether it's a **default** lens Erdo ships or one **generated** for your org. Default lenses carry bootstrap trust and may block a build before any calibration exists; a generated judge enters *untrusted* and has to earn screening power from its record. Below that is the judge's calibration, made legible without a query: its **pairwise agreement** and sample counts, a breakdown **by artifact kind**, and how many of its scored pairs came from the **random arm** — the bets it did *not* gate, the only place its screening value is directly measurable. The **misordered pairs** panel is the point: each row is a case where reality ranked two variants the opposite way the judge did — grounded numbers, not prose. This is what answers *"why did the critic block this?"* without touching SQL, and it's also the seed the engine uses to generate a sharper judge. A **recent predictions** list rounds it out with the forecasts the judge has been making lately across every experiment you can see. ## The decision policy For **page experiments**, prose decision rules and fixed sample sizes are slow — our traffic takes weeks to reach a fixed N, and nothing moves traffic while the evidence accumulates. The **decision policy** replaces both. It runs as the measurement recipe's final step and does three things from the same per-variant counts the recipe already read: * **Belief.** Each variant gets a Beta-Binomial posterior on the primary metric's rate. If the [persona panel](/persona-panel) has a *calibrated* prediction for the variant, that prediction seeds the posterior as pseudo-sessions — but only once the calibration ledger holds enough paired experiments **and** its falsification test passes; until then the prediction carries zero weight and the belief is data-only. A calibrated prediction is capped so a real read of a few hundred sessions always dominates it. * **Allocation.** A constrained Thompson-sampling step nudges each variant's `allocation_percent` toward its posterior win-frequency, but never more than 15 points a day, never below a 10% floor while a variant is live, and never dropping the control below 20% until the experiment decides. The caps make it boring by construction: a bad prior or a bad day shifts spend modestly, never zeroes a variant out. A step that moves more than 30 points in total raises an [attention item](/attention). * **Stopping.** A posterior rule replaces the prose one: ship a variant when it's ≥ 95% likely to beat the control (with an expected-loss guard against winning by a hair on huge uncertainty), kill it when it's ≤ 5% likely, or declare inconclusive at a 28-day horizon. A guardrail whose posterior is > 90% likely to have regressed vetoes a ship. When the rule settles on ship or stop, it fires the same decision loop the prose path uses. The written outcome also reports a **prediction-augmented effect** (an HAIPW estimate) alongside the naive one — a tighter confidence interval when the prediction is good, and never a biased point estimate when it isn't. It's reported, not gating: decisions ride the posterior rule above. Read the policy's latest state — per-variant posteriors, current allocation, win frequencies, stop probabilities, the calibration gate, and the recommended decision — with `erdo experiment policy `, the `erdo_get_experiment_policy` MCP tool, or `GET /v1/experiments/:slug/policy`. It's also attached to the experiment GET response as `policy_state`. ## Programmatic access Everything below is **org-scoped and RBAC'd** (project *view* for reads, *contribute* for writes) and referenced by **slug**. ### CLI ```bash theme={null} # inspect erdo experiment list --status running erdo experiment get acme-cpl-ab erdo experiment observations acme-cpl-ab --type metric_read # the loop's evidence erdo experiment observations acme-cpl-ab --judge artifact-critic # one judge's forecasts here erdo experiment policy acme-cpl-ab # decision-policy state (page experiments) # create + drive — variants, evidence datasets and the decision rule in one call erdo experiment create \ --project acme \ --slug acme-cpl-ab \ --title "Landing page CPL A/B" \ --workstream acme-lead-engine-2026-06 \ --primary-metric cost_per_lead \ --hypothesis "Variant B's shorter form lifts conversion" \ --decision-rule "Ship B if its lead_submit_rate beats control by 2σ over 7 days" \ --guardrail bounce_rate \ --variant "key=control,label=Current page,control,page=" \ --variant "key=b,label=3-field mobile form,alloc=50,page=" \ --evidence "slug=acme.funnel-events,role=primary" \ --evidence "acme.leads:guardrail" erdo experiment set-status acme-cpl-ab running erdo experiment decide acme-cpl-ab --decision ship --outcome "B cut CPL 22% over 2 weeks, shipping" # the trust dial — each judge's paired predictions and pairwise agreement vs reality erdo experiment calibration --project acme erdo experiment calibration --judge artifact-critic # one judge's track record # rank the variants pairwise (comparisons persist to the ledger, so the judge earns calibration) erdo experiment tournament acme-cpl-ab --criteria "which page converts cold paid traffic best" \ --kind landing_page --max-comparisons 20 # record that you acted against the allocator's recommendation for one variant erdo experiment record-decision acme-cpl-ab \ --variant b --recommended hold --chosen kill --reason "client pulled the offer" ``` Each `--variant` is comma-separated `key=value` pairs plus the bare flag `control` (marks the baseline). `page=` is shorthand for `type=page` + that resource id — it links the variant to the page it serves. `alloc=` is the traffic split (0–100). Each `--evidence` is `slug=,role=primary` or the shorthand `:` (role defaults to `primary`). `tournament` requires `--criteria` and accepts optional `--kind`, repeated `--variant ` flags to restrict the field, and `--max-comparisons`; it runs synchronously and can take minutes. `record-decision` logs an allocation override — the variant, the allocator's `--recommended` action, the action you `--chosen`, and a `--reason` — and that disagreement becomes calibration data on the allocator itself. ### MCP tools | Tool | What it does | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `erdo_list_experiments` | List experiment runs (filter by workstream / scope / status). | | `erdo_get_experiment` | One experiment by slug — hypothesis, variants, evidence, decision. | | `erdo_get_experiment_policy` | The decision policy's state — posteriors, allocation, stop probabilities, calibration gate, recommendation. | | `erdo_create_experiment` | Start an experiment with variants, decision rule, primary metric, evidence datasets. | | `erdo_update_experiment` | Flip status or record the decision + outcome. | | `erdo_list_experiment_observations` | Read the append-only observation log — metric reads, predictions, comparisons, decisions. | | `erdo_judge_calibration` | Each judge's track record — paired predictions and pairwise agreement vs measured reality (the trust dial). | | `erdo_run_pairwise_tournament` | Rank an experiment's variants by pairwise comparison (Bradley–Terry); comparisons persist to the ledger so the judge earns calibration. | | `erdo_record_allocation_decision` | Record that you acted against the allocator's recommendation for a variant — the disagreement calibrates the allocator. | ### REST Base URL `https://api.erdo.ai`. `Authorization: Bearer ` + `X-Organization-ID`. | Method | Path | | ------- | -------------------------------------------- | | `GET` | `/v1/experiments` | | `GET` | `/v1/experiments/:slug` | | `GET` | `/v1/experiments/:slug/policy` | | `POST` | `/v1/experiments` | | `PATCH` | `/v1/experiments/:slug` | | `GET` | `/v1/experiments/:slug/observations` | | `POST` | `/v1/experiments/:slug/tournament` | | `POST` | `/v1/experiments/:slug/allocation-decisions` | | `GET` | `/v1/judge-calibration` | `/v1/experiments/:slug/tournament` requires `criteria` in the body and accepts optional `kind`, `variant_keys`, and `max_comparisons`; it responds synchronously with the ranking (can take minutes). `/v1/experiments/:slug/allocation-decisions` takes `variant_key`, `recommended_action`, `chosen_action`, and `reason`. `/v1/experiments/:slug/observations` takes optional `variant_key`, `observation_type`, `judge_slug` (filter to one judge's forecasts within the experiment), and `limit` query parameters. `/v1/judge-calibration` takes optional `project_slug` (limit to one project; omit to span every project you can view) and `limit` (max ledger rows scanned) query parameters. # Filters Source: https://docs.erdo.ai/filters Saved rules that keep the rows matching their conditions on every read of a dataset — manage them in chat, the dataset settings UI, the CLI, MCP, or REST # Filters A **filter** is a saved rule on a dataset that **keeps the rows matching its conditions** and hides the rest. Once you add one, it applies *everywhere* that dataset is read — row lists, dashboards, public pages, and the agent's own queries — including counts and totals, not just the visible rows. The model is simple: **capture everything at write time, filter on read.** Nothing is ever deleted; the rows a filter excludes are just hidden from view. Because a filter keeps the rows that *match*, you **exclude by matching the rows you want to keep**. The most common use is keeping **test and QA submissions** out of a landing page's real leads: if the test rows all share a value — a test email like `@example.com` — you keep the real ones with `email not_contains @example.com`. Using `contains` there would do the opposite, keeping *only* the test rows. Other uses: keep a single status (`status` equals `won`), drop an archived one (`status` not\_equals `archived`), or scope to a window (`created_at` greater than a date). Filters apply to **file datasets** (uploads, lead/form capture, agent-written tables). Integration datasets enforce access through their source system, so they don't take filters. ## How a filter is shaped Each filter has an optional **name**, and one or more **conditions** combined with AND. A condition is a `column`, an `operator`, and a `value`: | Operator | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `equals` | column equals the value | | `not_equals` | column does not equal the value | | `contains` | column contains the value (case-insensitive substring) | | `not_contains` | column does **not** contain the value (case-insensitive) — **and rows where the column is empty/NULL are kept**, since a blank isn't a match. This is the operator for exclusion (e.g. `email not_contains @example.com`). | | `greater_than` | column is greater than the value | | `less_than` | column is less than the value | | `between` | column is between two values — pass them comma-separated, e.g. `10,100` | A filter is **default-on** unless you say otherwise: it applies automatically to every read. A read can opt out (`apply_default_filters=false` on the query API) to see every row — handy for a one-off admin or debug check. ## Default vs. named filters A filter can also be saved **not default-on** (set `is_default=false` when you create it). A non-default filter never applies on its own — it sits on the dataset as a *named view* that a specific reader can opt into by name, one request at a time. This is how a consumer of a **shared** dataset narrows what *they* see without changing the dataset for everyone else: the data owner defines the filter once, the consumer opts in. Opting into a named filter is **additive** — it applies *on top of* the dataset's default filters, narrowing the result. It can never be used to bypass a default (a default that hides test leads stays applied even when you opt into a named view). ### Opting into a named filter on a read The `query` and `fetch` read APIs take a `filter_names` list. Each name must match a saved filter on the dataset; an unknown name is rejected with the available names, so a typo never silently returns unfiltered data. ```bash CLI theme={null} # Fetch rows, opting into a saved filter by name (repeat --filter for more than one) erdo datasets fetch --filter "Won only" # Combine with a SQL shape and a row cap erdo datasets fetch leads --sql "SELECT * FROM data" --filter "Exclude smoke tests" --limit 500 ``` ```bash REST theme={null} curl -X POST https://api.erdo.ai/v1/datasets/leads/fetch \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filter_names": ["Exclude smoke tests"] }' # Same field on the SQL query endpoint curl -X POST https://api.erdo.ai/v1/datasets/leads/query \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "SELECT * FROM data", "filter_names": ["Exclude smoke tests"] }' ``` The MCP tools `erdo_run_query` and `erdo_fetch_dataset_contents` take the same `filter_names` argument. List a dataset's filter names with `erdo_list_dataset_filters` (or `erdo datasets filter list`). ## In chat The easiest way — just ask: > "Add a filter to the leads dataset to hide submissions from anyone testing — their > emails all end in `@example.com`." The agent adds the filter, and you can ask it to list or remove filters too. To see everything again for a one-off check, ask it to query with filters off. ## In the dataset settings Open a dataset and find the **Filters** section. **Add filter** opens a form where you name the filter and add one or more conditions; existing filters are listed with their conditions and a remove button. Removing a filter stops it hiding rows — it never deletes data. ## CLI ```bash theme={null} # List the filters on a dataset erdo datasets filter list # Exclude test submissions — KEEP the rows whose email does NOT contain @example.com # (repeat --where for each condition; they AND-combine) erdo datasets filter add \ --name "Exclude test submissions" \ --where "email not_contains @example.com" # Save a NAMED VIEW instead (opt-in, not default-on) with --no-default erdo datasets filter add leads \ --name "Won only" \ --where "status equals won" \ --no-default # Multiple conditions, and the 'between' form erdo datasets filter add leads \ --where "status not_equals archived" \ --where "score between 10,100" # Remove a filter by id (from 'filter list') erdo datasets filter remove ``` Each `--where` is a ` ` string. Output is JSON. ## MCP tools | Tool | Description | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `erdo_add_dataset_filter` | Add a filter to a dataset. Takes `dataset_slug`, optional `name`, `conditions` (`column`/`operator`/`value`), and optional `is_default` (default true; false saves a named view). | | `erdo_list_dataset_filters` | List the filters on a dataset (`dataset_slug`). Returns each filter's id, name, conditions, and whether it is default-on. | | `erdo_remove_dataset_filter` | Remove a filter by `dataset_slug` and `filter_id`. | | `erdo_run_query` / `erdo_fetch_dataset_contents` | Read rows; pass `filter_names` to opt into named (non-default) filters, additive on top of the defaults. | ## REST | MCP tool | REST endpoint | Method | | ---------------------------- | --------------------------------------------- | ------ | | `erdo_list_dataset_filters` | `/v1/datasets/:datasetSlug/filters` | GET | | `erdo_add_dataset_filter` | `/v1/datasets/:datasetSlug/filters` | POST | | `erdo_remove_dataset_filter` | `/v1/datasets/:datasetSlug/filters/:filterID` | DELETE | ```bash theme={null} # List filters curl "https://api.erdo.ai/v1/datasets/leads/filters" \ -H "Authorization: Bearer YOUR_API_KEY" # Add a filter (default-on) curl -X POST https://api.erdo.ai/v1/datasets/leads/filters \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Exclude test submissions", "conditions": [ { "column": "email", "operator": "not_contains", "value": "@example.com" } ] }' # Add a named view (opt-in only) with is_default:false curl -X POST https://api.erdo.ai/v1/datasets/leads/filters \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Won only", "is_default": false, "conditions": [ { "column": "status", "operator": "equals", "value": "won" } ] }' # Remove a filter curl -X DELETE https://api.erdo.ai/v1/datasets/leads/filters/ \ -H "Authorization: Bearer YOUR_API_KEY" ``` Adding or removing a filter needs **edit** access to the dataset; listing needs **view** access. # Org history Source: https://docs.erdo.ai/history An append-only record of the consequential actions in your organization — approvals decided, pages published, permissions and flags changed, members added or removed — each one attributed to whoever performed it, on whose behalf, and under which approval. # Org history The Activity feed answers "what needs me right now?"; the **History** answers a different, quieter question — "what happened?". When you come back from a week away, when a page turns out to be public that shouldn't be, or when you want to know exactly what Erdo did on its own while you weren't watching, you need a durable record of the consequential things that changed in your organization, each one attributed to whoever caused it. That record is the org history. It is append-only. An entry is written the moment an action happens and is never edited or removed afterwards, so the history is a faithful account of the past rather than a view of the present that rewrites itself as things change. That is the property that makes it worth trusting when something has gone wrong. ## What it records History captures the actions that have consequences worth answering for later — not every read, click, or intermediate step, but the mutations an operator would want a trail of: * **Approvals** — when an approval is requested and when it is decided, who decided it, and what it authorized. The decision is kept even after the approval request itself ages out, because "who approved this, and when" is precisely the record you want when an approved action turns out badly. * **Publishing** — when a page is made public and when it is taken back private, and who did each. Un-publishing is recorded just as publishing is. * **Permissions** — when access to a resource is granted, changed, or revoked, with the access level before and after. Sharing by email is recorded in two parts, because it happens in two parts: the invitation when you send it, and the grant when the recipient signs up and it takes effect, attributed to whoever invited them rather than to the system. * **Feature flags** — when a capability is turned on or off for your organization, and by whom. * **Membership** — when someone is added to or removed from the organization, or their role changes. * **Destructive and configuration changes** — deleting a dataset, changing an integration's configuration, and similar actions an administrator most wants a trail for. Every entry carries the same attribution, because the whole point is to be able to tell one actor's actions apart from another's. Each records **who** performed it — a specific person, a named agent, or Erdo itself acting as the system — and, where it applies, **on whose behalf** the action ran and **which approval** authorized it. This chain is what lets you distinguish "Jeremy published this page" from "the repair agent republished it, under the approval Jeremy granted an hour earlier" — two very different facts that, without the chain, look identical. That attribution matters more as Erdo takes on more of the work itself. An agent that acts independently is only trustworthy if its actions leave a trail you can read, and the history is that trail: every agent or system action wears its authorization plainly — approved by a named person, or autonomous with no approval required — so "show me everything the agents did this week" is a question you can actually answer. ## Three ways to read it **In the app.** The **History** view sits alongside the Activity feed and reads as a story rather than a table. Actions that belong together — an approval and the agent work it unlocked — are grouped into a single episode you can expand to see the individual events beneath, so a rebuild that re-bound four datasets reads as one thing that happened, not five disconnected rows. Every entry is a plain sentence naming the actor, what they did, and the resource, with its authorization shown as a badge. **Over the API.** `GET /v1/activity/history` returns the raw events, newest first, for a headless caller or a dashboard built on top of Erdo. It is read-only and org-scoped, authorized like every other Platform API call. **On the command line.** `erdo history` is built for forensics — precise filters over a time window, printed as scannable sentences. It is the fastest way to answer an incident question from a terminal. ```bash theme={null} # everything that happened in the last day (the default window) erdo history # who changed permissions on one page this week, and under whose authority erdo history --resource page:9f3c2a10 --since 7d # only what the agents and the system did on their own — the autonomy review erdo history --actor-kind agent --since 3d # a specific person's recent actions, as raw JSON for a script erdo history --actor usr_1a2b3c4d --since 48h --json ``` The command groups related events into episodes exactly as the app does: an initiating action heads a block and the consequences it authorized are indented beneath it, each line stamped with the local time. An episode header shows the approval that authorized it, or marks the action autonomous when an agent or the system acted with no approval required. ## Filtering Every surface shares the same filter set, so a question you can ask in the app you can ask on the CLI or over the API: * **Time window** — `--since` and `--until`. The CLI accepts a relative duration like `2h`, `3d`, or `1w`, or an exact RFC3339 timestamp; `--since` defaults to the last 24 hours. * **Actor kind** — `--actor-kind` narrows to a person, an agent, the system, or a token, which is how you separate what people did from what Erdo did. * **Actor** — `--actor` limits to a single user's actions. * **Resource** — `--resource ` scopes to one thing, like `page:`, to reconstruct its whole story. * **Verb** — `--verb` selects specific kinds of action, comma-separated, such as `approval.decided,page.published`. * **Limit** — `--limit` caps how many events come back. ## When history begins History is append-only and starts recording from the moment the feature is enabled for your organization — there is no reconstruction of actions that happened before then. Older facts remain wherever they already lived (a page's revision history, a dataset's own event log), and the history links out to those deeper records rather than duplicating them: it tells you *what* happened and who caused it, and the owning resource shows you exactly *how*. If a filter returns nothing, it usually means either nothing matched or the window reaches back before the ledger was turned on. # Inboxes Source: https://docs.erdo.ai/inboxes Give Erdo an email address it can receive at — so an agent can sign up to a service, read the verification code, and keep going on its own. An **inbox** is an email address Erdo owns and can receive mail at. Create one and you get a unique address; anything sent to it (a verification code, a magic link, a reply) is captured and readable — by you and by the agent that needs it. The most common use: an agent needs to **sign up to a service or app** to do a job, and that service emails a confirmation code. The agent creates an inbox, uses the address to sign up, then reads the code and carries on — no human relaying emails. ## How it works You (or an agent, on your behalf) create an inbox and get back a unique address like `inbox-ab12cd34@mail.erdo.ai`. Enter it wherever an email is required — a signup form, a newsletter, a service that sends a one-time code. Erdo captures mail sent to that address and surfaces the message — subject, sender, body, and any **verification code** or **links** it spots. ## Private by default Every inbox is **owned by you**. Only you — and an agent acting on your behalf — can read its mail. Addresses are unguessable, and Erdo only captures mail sent to an address you actually created; it does not collect arbitrary mail sent to the domain. Delete an inbox and its stored messages go with it. ## Agents and inboxes Because inboxes are private and owned, agents can create and read their own freely. A typical agent flow: > "Sign me up for a free trial of that analytics tool and pull my first report." The agent creates an inbox, signs up with the address, reads the verification code from the inbox, completes signup, and gets to work — all in one [conversation](/concepts#conversations). Inboxes are for **receiving** mail. To **send** email (a report, an alert), an agent uses its email-sending tools instead — see [Automations](/automations). # Connecting integrations Source: https://docs.erdo.ai/integrations Connect a third-party app to Erdo from the CLI, an MCP client, or the API — one call when you hold the credential, a browser authorization when the provider mints it. An **integration** is a third-party app Erdo can act in on your behalf: a database it queries, a warehouse it builds datasets from, a SaaS API an agent or a script calls. Connecting one is the moment its credentials become available to your organization, and it happens in one of two ways. The thing worth internalizing before you start is **what decides which way you get**. It is not whether the app is one of Erdo's own native integrations or one of the thousands fronted by our connector platform. It is simply this: **do you already hold the credential?** * **You hold it** — a database password, an API key, a service-account JSON. There is nobody to send anywhere, so you pass it and the connection exists when the call returns. This is true for native integrations *and* for SaaS apps whose auth type is `keys`. * **You don't hold it** — the app authorizes with OAuth, which means the provider mints the credential *during* the authorization. There is nothing you could pass, because it does not exist yet. You get back a `connect_url`, somebody opens it in a browser, and a status check confirms the result. That asymmetry is why passing credentials to an OAuth app is **rejected with an error** rather than accepted. An error naming the reason is the only honest answer: the alternative is a call that appears to succeed while storing nothing. All three surfaces — the CLI, the `erdo_connect_integration` MCP tool, and `POST /v1/integrations-connect` — run the same code, so everything below behaves identically whichever one you drive. ## Which organization gets the connection A connection belongs to **one organization**, and every surface that starts one names that organization before you commit to it. This matters because the destination is never something you type: it comes from the org the CLI is pinned to, or the workspace you are looking at in the browser, or the `X-Organization-ID` header your integration sends — and most people at a company using Erdo can reach more than one. A production Stripe account attached to the wrong organization is invisible until something starts syncing there. In the web app, the connectors table says whose it is above the list, and each connect dialog repeats it beside the credential fields. Over the API and the CLI the answer comes back with the result, as `organization_name` and `organization_slug`, and the `next_step` line names it too: ```bash theme={null} erdo integrations connect slack # { "app": "slack", "status": "pending", "connect_url": "…", # "organization_name": "Acme Inc", "organization_slug": "acme" } # # This connection belongs to Acme Inc (acme). # Open this URL in a browser to authorize, then run: erdo integrations status slack ``` That is the org the request actually carried, not a restatement of what you believed you had pinned — so if it names an organization you did not intend, the connection has not been authorized yet and you can start again with `erdo --org integrations connect `. `erdo whoami` answers in the same shape, so the two agree. ## Find the app and how it authorizes Search the catalog before connecting. The result tells you the identifier to pass and the auth type that decides your flow: ```bash theme={null} erdo integrations apps postgres # postgres native database PostgreSQL erdo integrations apps slack # slack pipedream oauth Slack ``` The columns are the app identifier, its source (`native` or `pipedream`), its auth types, and its display name. Over REST the same catalog is `GET /v1/integration-apps?query=postgres`. An empty query lists native integrations plus popular connectable apps. When a native integration and a catalog app share an identifier, the native one wins — so the identifier you pass to `connect` always resolves the same way it did in `apps`. | Auth type | Source | How it connects | | ------------------------------------------------------------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `database`, `api_key`, `service_account`, `basic`, `aws_iam` | native | Pass credentials. Erdo verifies them against the provider and activates the integration in the same call. | | `keys` | catalog app | Pass credentials. They are stored with the connector platform and the connection is active immediately — but nothing calls the vendor, so they are **not** validated here. | | `oauth`, `oauth2`, `oauth1` | either | No credentials. You get a `connect_url` to open in a browser. | | `none` | native | No credentials at all — it connects on the spot. | ## Connect with credentials ### CLI `-c` takes one `key=value` per credential field and repeats: ```bash theme={null} erdo integrations connect postgres \ -c host=db.example.com -c port=5432 -c database=analytics \ -c username=readonly -c password=secret \ -n "Production DB" erdo integrations connect apollo -c api_key=$APOLLO_KEY -n Apollo ``` The field names are the ones the app declares — `api_key` for a key-auth app, the connection fields for a database. Get one wrong and the connect call fails rather than half-succeeding; for a catalog app the provider's own message about the offending field is passed straight back to you. ### MCP `erdo_connect_integration` takes the same thing as a `credentials` object: ```json theme={null} { "app": "apollo", "name": "Apollo", "credentials": { "api_key": "abc123" } } ``` ### REST ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/integrations-connect" \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "app": "apollo", "name": "Apollo", "credentials": {"api_key": "abc123"} }' ``` All three answer with the same shape — `app`, `integration_id`, `status`, and a `next_step` line saying what is actually known: ```json theme={null} { "app": "apollo", "integration_id": "0f2c…", "status": "active", "next_step": "The integration is connected and ready to use." } ``` ### Verified, or merely stored The distinction matters when something later fails, so the two cases say different things. A **native** credential integration is created, exercised against the provider, and activated in the one call. Credentials that don't work fail the call, and the half-made integration is removed rather than left behind for you to trip over — so `status: "active"` means Erdo has talked to the provider with those exact credentials. A **`keys` catalog app** is different, and its `next_step` says so plainly: *the credentials are stored and the integration is ready to use; they were not validated against the provider — the first action run is what confirms them*. The connector platform saves what it is handed without calling the vendor, so a success here means "stored", not "correct". Treat the first action you run as the real test. ## Connect an OAuth app Connect it with no credentials. You get `status: "pending"` and a URL: ```bash theme={null} erdo integrations connect slack # ... connect_url ... # Open this URL in a browser to authorize, then run: erdo integrations status slack ``` Open the URL, authorize, then check status — that call reconciles the connection server-side, so a headless caller finishes the flow without ever touching a browser SDK. Over REST and MCP, `return_url` sends the person's browser back to your own page after they authorize instead of Erdo's data page — worth setting when you are embedding the connect flow in a product of your own. It must be an absolute `http(s)` URL with no embedded credentials, and it is ignored by apps that connect in one call. Passing `credentials` to an OAuth app is an error, not a no-op. The message explains why the app cannot take them — an OAuth provider mints the credential during the authorization, a no-auth app has none — and points you at the flow that does work. ## Check status ```bash theme={null} erdo integrations status apollo erdo integrations list # everything connected: app, status, auth type, name ``` Over REST: `GET /v1/integrations-connect/{app}`. Either way you get the app, a `connected` boolean, the matching integrations, and an optional `note` counting connections confirmed by this call. **"Not connected" is an ordinary answer**, not a failure: ```json theme={null} { "app": "slack", "connected": false, "integrations": [] } ``` This is worth stating because it used to be a `503`. Our connector platform does not materialise a record for an end user until that user's first connection completes, and asking about a user it has never seen answers *not found* — which is the same fact as "you have connected nothing", not an outage. Every status check for every catalog app failed that way for anyone starting from zero, which is exactly when you are most likely to be checking. A real provider failure still surfaces as an error, so the signal you want kept its meaning. ## Reconnect a connection that has expired A provider can withdraw a grant at any time — a password change, an administrator revoking app access, a refresh token that quietly aged out — and the connection then reads **Needs reauth** wherever Erdo shows it. Nothing about the connection is wrong except its credential, so the fix is to authorize it again rather than to remove it and start over. Open **Data → Connectors**, find the row reading *Needs reauth*, and press **Reconnect**. If that connector holds several accounts, expanding the row gives you one Reconnect per account so you repair the right one. The button re-runs the same authorization you did originally against the connection you already have, which is what keeps its datasets, its schedules and its chosen provider account intact — disconnecting and connecting again would leave all of that behind. Where Erdo mentions the problem elsewhere — a page whose data has gone stale, a sync failure notification — it links to the same place. Reconnecting is also how a connection picks up permissions Erdo has since started asking for: the authorization requests the app's current set of scopes, not the set granted the day you first connected, so the provider shows you the consent screen again and the new permission is granted with the rest. If the person who originally authorized the connection has left, or the credentials belong to someone outside your organization, hand the reconnect to them with a [connect link](/connect-links) instead of sharing a login. ## Disconnect ``` DELETE /v1/integrations/{id} ``` Removes a connection by the id `erdo integrations list` (or `GET /v1/integrations`) shows. The stored credential is deleted and datasets built on the integration are cleaned up — the same thing removing the connection in the web app does. The response names what was removed (`id`, `app`, `name`), and an id your organization does not hold answers `404`. ## Set account identity ``` POST /v1/integrations/{id}/identity { "account_id": "1111111111", "login_customer_id": "5302012239" } ``` A connection can carry two non-secret identity ids inside its stored credentials: `account_id` — which provider account the connection is — and, for Google Ads agency (MCC) connections, `login_customer_id` — the manager account every request against a client account is sent in the context of. `GET /v1/integrations` returns both; this endpoint sets them. Most connections never need this. A Google Ads OAuth connection whose token can reach exactly one account, and that account is a manager, gets its `login_customer_id` detected and stored at authorization time. But a token that can reach several accounts is ambiguous — no detector can know which one you mean — and connections made before identity stamping existed carry no `account_id` at all. In both cases you state the identity here. At least one field is required; a field you omit is left untouched, so you can set one id without knowing the other. Ids are normalized before storage: spaces and dashes are stripped, because Google renders customer ids as `530-201-2239` while its API wants `5302012239`. Only `login_customer_id` has to be numeric, being a Google Ads manager id; `account_id` takes whatever shape its provider issues, so a Meta `act_1234567890` and a Reddit `a2_bzv32cgqp` are both stored as given. There are no delete semantics — a wrong id is corrected by writing the right one. Every other credential the connection stores (tokens, expiry) is untouched by this write. Setting `account_id` on a connection that chooses a provider account also records it as [the account that connection operates](/connection-accounts) — one account level, one unambiguous id — so requests actually go there rather than the id being stored and ignored. A provider reached through an owner (a Reddit business, a GA4 account) needs the whole path, so use the connection-scope endpoint for those. The response is the integration's summary in the same shape `GET /v1/integrations` returns, carrying the identity now stored — never any token or secret material. An id your organization does not hold answers `404`, indistinguishable from an id that never existed. ## Apollo, end to end Apollo is the worked example of a key-authenticated integration: you paste a key and it is connected, with no browser step anywhere in the flow. ```bash theme={null} erdo integrations connect apollo -c api_key=$APOLLO_KEY -n Apollo erdo integrations status apollo ``` The key comes from Apollo under **Settings → Integrations → API**. Erdo stores it encrypted and sends it to Apollo in the `X-Api-Key` header, never as a URL parameter — a key in a URL ends up in every log line that records one. Connecting confirms Apollo is **reachable**; it cannot confirm your key. Apollo's key-test endpoint answers `200` even to a key that is not a key at all, so verification has nothing to fail on. The first `enrich_person` call is what names a bad key, and it says so explicitly when Apollo rejects it. ### enrich\_person Apollo has one action. It takes an email address and returns what Apollo knows about that person professionally: ```json theme={null} { "email": "ada@example.com" } ``` Email only, deliberately. Apollo will also match on a name plus a company domain, but that form guesses — it returns whoever fits best, and you cannot tell a confident match from a plausible one. An address is an identity you already hold for anyone who filled in a form, so the answer is either about that person or about nobody. When Apollo has a record: ```json theme={null} { "found": true, "email": "ada@example.com", "name": "Ada Lovelace", "title": "VP Engineering", "linkedin_url": "https://linkedin.com/in/…", "city": "London", "state": "", "country": "United Kingdom", "organization": "Analytical Engines Ltd", "organization_website": "https://example.com" } ``` When it doesn't, you get `found: false` and the address, and nothing else. **`found` is the field to branch on.** A miss is a successful answer, not an error — Apollo simply has no record of that address. Because a miss returns no other keys, writing the result straight into a dataset cannot quietly produce a row of empty strings that looks like a person nobody knows anything about. Genuine failures — a rejected key, a rate limit, an Apollo outage — come back as errors, never disguised as `found: false`. Each successful match costs one Apollo credit. Personal email addresses and phone numbers are never requested and never returned: asking for them turns a one-credit lookup into as many as nine and pulls contact details into Erdo that nobody asked for, so the request omits them and the result has no field to carry them. ### Calling it unattended `enrich_person` reads and changes nothing — not in Apollo, not in Erdo — so it needs no approval, which is what lets an automation running at 3am use it. A [scripted automation](/automations#agent-or-script) or an [event pipeline](/event-pipelines) step invokes it directly: ```js theme={null} const person = actions.invoke("apollo", "enrich_person", { email }); if (person.found) { // person.title, person.organization, person.linkedin_url … } ``` The script never holds the Apollo key — it asks for an enrichment and gets a person back, and the credential stays in Erdo's encrypted storage. Agents reach the same action through `run_integration_action`. ## Related The `erdo integrations` commands in context with the rest of the CLI. The integration tools an AI assistant drives, and their REST mirrors. Scripted work that calls a connected app on a schedule or a trigger. Turning a connected database or warehouse into a queryable dataset. # Introduction Source: https://docs.erdo.ai/introduction Erdo connects to your business data, answers questions, creates useful outputs, and runs repeatable work. Erdo Erdo Erdo is an AI workforce for your business. Ask it to investigate a problem, analyse your data, build something useful, or run a repeatable process. Erdo coordinates the right capabilities internally, shows you the result, and asks before doing anything consequential. ## Two ways to start Use a **conversation** when the work is open-ended: a question, investigation, analysis, or one-off deliverable. Describe the outcome in plain language and Erdo will work out the steps. > "Pull last month's signups by plan, flag anything unusual, and build me a > dashboard I can share with the team." That request can connect to your data, run the analysis, and produce a live [page](/pages) in the same conversation. Use a **template** when the outcome has a defined flow. A template asks for the missing brief and keeps the progress, outputs, blockers, and decisions visible outside the conversation. This is useful for work such as a campaign launch or lead engine that should not depend on following a long transcript. ## Return to the work The sidebar keeps primary navigation direct and separates reusable setup: * The project switcher at the top left changes the context for sustained work and provides **New project** and **Manage projects** actions. * **Home** starts or resumes work, **Activity** shows what changed and what needs a decision, **Pages** holds durable outputs, and **Agents** holds persistent workers your organisation builds and operates. * **Setup** contains **Knowledge** for your business context and ontology, plus **Data & connections** for datasets and connected systems. Templates are offered on Home with a permanent **Browse all** link, and recent conversations remain close at hand without becoming another top-level product area. Important outputs do not have to remain buried in a conversation. [Pages](/pages) are durable, shareable apps and dashboards. Pin the pages that define a project so they stay on its home and in its sidebar. ## Key concepts Start from a plain-language request or a template, then resume recent work. See what changed, what is running, and what needs your decision. Keep shared context, conversations, progress, and pinned outputs together. Durable, shareable apps and dashboards connected to live data. Persistent workers with their own instructions, knowledge, deployments, and activity. Start defined outcomes with visible progress, outputs, and decisions. Connect data and shape the reusable knowledge Erdo works from. ## Use Erdo anywhere Erdo is not limited to the web app. Connect Claude, Cursor, or another MCP client with the [MCP server](/mcp/overview), or drive Erdo from the [CLI](/cli). Building software on Erdo? The [REST API](/api/overview) and the [TypeScript](/ts-sdk/overview) / [Python](/sdk/invoke) SDKs live in the **Developers** tab. Connect a data source and get to a useful result in a few minutes. # Judges and calibration Source: https://docs.erdo.ai/judges A judge is anything that predicts which variant wins before reality measures it — a critic lens, a persona panel, a pairwise tournament, your own answer to a choice. Calibration is the track record that says which ones to trust. # Judges and calibration Real traffic is slow and expensive: it takes weeks and hundreds of dollars to find out which landing-page variant actually converts. A **judge** is anything cheaper than reality that predicts that answer *before* reality speaks — a critic lens grading a page, a [persona panel](/persona-panel) forecasting conversions, a [pairwise tournament](#pairwise-tournaments) ranking a field, or your own answer to a [choice](/attention) in the Activity feed. Judges are fast, synthetic, and fallible, so none of them settles a bet on its own. What makes them useful is that every prediction is checkable: when reality later measures the same variant, you find out whether the judge was right. That check is the whole discipline. A judge with a good track record earns the power to screen out weak variants before you spend on them; a judge whose predictions stop matching reality loses that power automatically. The rest of this page is how that track record is kept, and the surfaces for running the judges and reading their records. ## Calibration — the trust dial **Calibration** is the pairing of a judge's forecasts against the outcomes reality later measured. Because a judge's predictions and reality's measurements land in the same [experiment observation ledger](/experiments) — with the same variant and metric keys — calibration is a **query over that ledger, not a separate subsystem.** It stays honest as more observations land, because there is nothing to keep in sync: the same rows that record what happened also record what was predicted. The headline number is **pairwise agreement**: of the variant pairs where both the judge and reality expressed an ordering, how often did the judge order them the way reality did. It covers `prediction` rows (two forecasts imply an ordering) and `comparison` rows (an explicit head-to-head) uniformly, each side oriented by its own metric's direction, so a judge that picked the *lower*-cost-per-lead variant scores as agreeing rather than disagreeing. Alongside agreement you get the raw prediction and comparison counts, how many predictions reality has caught up with (*paired predictions*), and a breakdown **by artifact kind** — because a judge validated on landing pages tells you nothing about how it grades dashboards, and its screening power is scoped to the kind it has actually earned it in. Read the calibration record on any surface: ```bash theme={null} # every judge's paired predictions and pairwise agreement vs measured reality erdo experiment calibration --project acme # one judge's track record erdo experiment calibration --judge artifact-critic ``` The MCP tool is `erdo_judge_calibration`; the REST mirror is `GET /v1/judge-calibration` (optional `project_slug` to scope to one project, `limit` to cap ledger rows scanned). Each judge also has a profile page under **Activity → Judges** that renders the same record without a query — its rubric, the kind it screens, whether it's a default lens Erdo ships or one generated for your org, and a **misordered pairs** panel showing exactly the cases where reality ranked two variants the opposite way the judge did. **Default judges are platform-calibrated, so they aren't blind on day one.** A new workspace has no paired outcomes for weeks. For the default lenses Erdo ships only, the calibration prior pools *statistics* across every workspace — agreement rates and sample counts, never anyone's page content, verdicts, or identity — so a default judge starts from the whole platform's record and is then pulled toward your own outcomes as they accumulate. Judges you author stay private to your workspace and never pool. ## Pairwise tournaments When a fan-out produces many variants of one brief, ranking them is the first rung of judgment. Erdo never asks a judge for an absolute score, because LLM judges are markedly more reliable at *"which of these two is better"* than at *"rate this one out of ten."* So a **pairwise tournament** compares variants two at a time and fits the verdicts into a ranking. The ranking is a **Bradley–Terry** fit, not a knockout bracket, and the reason is that pairwise preferences are legitimately intransitive — a panel can produce A beats B, B beats C, and C beats A. A bracket would turn that cycle into an arbitrary winner decided by the seeding. Bradley–Terry instead fits one latent strength per variant that best explains *all* the comparisons at once, so a variant that drops one comparison but wins many others still ranks above one that won that comparison and little else. Presentation order is randomized per pair (and recorded), which turns an LLM's position bias into noise the fit averages out; and near-duplicate variants are collapsed first so the tournament doesn't waste comparisons distinguishing a page from its own regeneration. Every comparison the tournament makes persists to the experiment ledger as a `comparison` observation, so the pairwise judge earns a calibration track record exactly like every other judge. Run one: ```bash theme={null} erdo experiment tournament acme-cpl-ab --criteria "which page will convert cold paid traffic best" \ --kind landing_page --max-comparisons 20 # restrict the field with repeated --variant flags erdo experiment tournament acme-cpl-ab --criteria "clarity of the offer" --variant b --variant c ``` The MCP tool is `erdo_run_pairwise_tournament`; the REST mirror is `POST /v1/experiments/:slug/tournament` (body: `criteria` required; optional `kind`, `variant_keys`, `max_comparisons`). The call is **synchronous and can take minutes** — it runs the comparisons before it returns the ranking, the merges it made, and what it persisted. ## Judge back-test — standing from the first minute A newly created judge has no track record, so it would sit advisory for weeks — unable to earn screening power from the very reality it was created to grade — because predictions only persist for variants that happen to be critiqued while an experiment is live. The **judge back-test** closes that gap: it runs the judge over your organization's currently-live experiments' bound variant pages *of its artifact kind* and persists one prediction per variant, so the judge earns (or fails to earn) calibration against outcomes you already know within its first minutes. The predictions are ordinary ledger rows, so they pair against the measured outcomes with no special-casing. It runs automatically when a judge is created **and whenever a judge's content changes** — an edited rubric, or a revision to the shared principles it reviews against. Re-runs are **idempotent per judge version**: a variant this judge has already forecast at its current content is skipped, while a genuinely changed judge re-forecasts each live variant exactly once under its new standard. When a *trusted* judge's re-screen finds blocking issues on variants serving live traffic, it raises a [judge re-screen item](/attention) in the attention feed proposing an iterate bet — it never rewrites a live page itself. ```bash theme={null} erdo judge backtest landing-conversion-v2 ``` The MCP tool is `erdo_backtest_judge`; the REST mirror is `POST /v1/judge-backtests` (body: `judge_slug`). It emits a summary of the predictions written. ## You are the most expensive judge Your own judgment is the most trusted judge in the cascade, so Erdo spends it sparingly and records it carefully. When the Activity feed asks you to [pick between variants](/attention) — a choice item tied to a running experiment — your answer is recorded as a `comparison`: the option you picked is marked as beating the ones you didn't, attributed to `human:`. That verdict does two things at once. It steers the live decision, and it **scores the cheaper judges against you** — every automatic judge that predicted the pair the way you did gains agreement, and every one that didn't loses it. So the minutes you spend answering choices aren't just deciding one experiment; they're sharpening the judges that will decide the next hundred without you. Because a human choice is just another judge, the calibration readout reports your own agreement rate the same way it reports a critic's — which is how you find out, over time, which automatic judge you can trust enough to stop being asked. # Knowledge Source: https://docs.erdo.ai/knowledge Your agents' shared brain — the metrics, definitions, skills, and learnings they build once and reuse everywhere. **Knowledge** (the **Brain** in the app) is what your agents know about your business. Instead of re-deriving the same things every thread, agents read from a shared, structured memory and add to it as they learn — so the workforce gets sharper over time and stays consistent across people and threads. It's one connected store, not a pile of notes: definitions link to the data they come from, learnings link to the work that produced them, and everything is typed so agents (and you) can reason over it. ## What's in it Business measures with definitions and formulas — revenue, CAC, ROAS — so everyone (and every agent) computes them the same way. The business objects you work with — campaigns, products, customers — and how they map to the tables in your data sources. The exact SQL behind a metric, how you segment "active users", and mandatory filters (like "exclude refunded orders") agents must always apply. Reusable procedures an agent can call to perform a task the same way each time. Pin them to an [agent](/agents) to give it a capability. Findings, limitations, and optimizations an agent picks up while working, kept so it doesn't relearn them. Caveats about a dataset or an API — its grain, freshness, known gaps, and quirks — so agents read it correctly. Erdo ships with a starting **ontology** — common entities and metrics so agents aren't working from a blank slate — and a library of built-in **skills** for things like data analysis, charting, and reporting. You add your own on top. ## Tied to your data Knowledge isn't free-floating prose. A metric or entity is **bound** to the datasets it comes from — which table, at what grain, keyed on which columns, with which filters. That binding is what lets an agent move from "revenue is defined as…" to actually running the right query against the right table. One definition is marked the **source of truth** when several could apply, so agents don't pick the wrong one. Already have this modelled elsewhere? Import it from **dbt**, **LookML**, **Power BI**, or **Tableau** and Erdo turns your metrics, entities, relationships, and access rules into knowledge. See [Bring your semantic layer](/data#bring-your-semantic-layer). ## Shared values with collections Some facts are exact values that show up in many places at once — your monthly price, this quarter's targets, brand tokens. Hardcoding them into each definition, skill, and page means they drift the moment one changes. Instead, store the value once in a **KV store** (a named collection — Erdo's shared key/value store) and reference it from Knowledge prose as `{{slug.key}}`: ```text theme={null} Our Pro plan is {{pricing.monthly}} per seat, billed monthly. Annual billing works out to {{pricing.annual}}. ``` When an agent reads that record, Erdo resolves each reference to the live value of that KV item — so `{{pricing.monthly}}` becomes `$29` everywhere, and updating the store updates every record and page that references it. The same KV stores are read by [pages](/apps/build-apps#per-page-state-kv) and managed over the [CLI](/cli#kv-collections) and [MCP/REST API](/mcp/overview#kv-collection-tools). References are permission-aware and **fail closed**: if a reference can't be resolved — unknown store, missing key, or no access — it renders as a visible `⟦unresolved: slug.key⟧` marker rather than a silent blank or raw braces, so a broken reference is obvious instead of quietly wrong. References resolve when the record is read, against the *live* KV value — so editing the store updates every record at once. ## Use it from your apps and tools Knowledge isn't only for agents in a thread. A [page](/apps/build-apps) can read a knowledge object directly as structured app data — `window.erdo.getKnowledgeObject(id)` returns the object plus its links, backlinks, and related objects, so an app can render a brand profile, a metric definition, or a glossary straight from the Brain instead of hardcoding it. And the same records are reachable over the [CLI](/cli#knowledge) (`erdo knowledge list` / `search`) and the [MCP/REST API](/mcp/overview#knowledge-tools) (`erdo_create_knowledge`, `erdo_search_knowledge`, …) — so coding agents and scripts read and write the same shared brain your in-app agents use. ## How agents use it As agents work, they lean on Knowledge in two directions: * **Search before acting** — before answering or running code, an agent searches Knowledge for your definitions, skills, and prior learnings, so it uses *your* meaning of a term instead of guessing. * **Propose after learning** — when an agent learns something durable ("this column is the revenue metric", "this API caps page size at 100"), it proposes adding it to Knowledge so the next thread benefits. Because the store is shared, a definition you approve once is used consistently by every agent and every person — and the workforce compounds what it knows instead of starting cold each time. ## Visibility — workspace vs. public Every Knowledge entry has a visibility that controls who it can be shown to: * **Workspace** (the default) — visible to people and agents inside your organization only. Nothing you add is exposed externally unless you say so. * **Public** — the entry is opted into **anonymous external surfaces**, such as the [website voice widget](/voice-widget) answering visitors on your site. Making an entry public **is** the publish decision: a draft is approved in the same step and goes live immediately, so only flip it when you mean it. If your widget can't answer something you've already added, check the entry's visibility — an approved entry that's still *Workspace* is invisible to it. You can change visibility on an entry in the Knowledge browser, or ask Erdo in chat to publish a set of entries for you. To see exactly what's public right now, set the Knowledge browser's visibility filter to **Public**, or list it programmatically: `erdo knowledge list --public` from the CLI, or `GET /v1/knowledge?visibility=public` from the API. Each agent's Knowledge tab also shows the same public set, since that's what its website deployments answer from. Reserve **Public** for customer-facing facts you'd happily tell any visitor: products, pricing, opening hours, locations, policies, FAQs, galleries. Internal processes, personal data, and financials stay *Workspace*. ## Keeping facts fresh Some knowledge goes stale — an API version changes, an enum is renamed, a rate limit moves. Erdo tags volatile facts with when they were last verified and when they should be re-checked, and marks them deprecated or superseded when they're replaced. Agents treat stale or unverified facts as a hint to confirm, and always prefer current tool definitions and live docs over an old note. ## Review what agents propose New knowledge an agent wants to add to the workspace shows up in the [review queue](/review-queue) before it becomes shared truth. Each proposal comes with the evidence behind it and the change it suggests, so you can accept what's right and reject what isn't — and the Brain stays trustworthy rather than filling with guesses. This is the same human-in-the-loop principle behind [approvals](/concepts#review-and-approvals) for actions, and the same queue is readable and answerable over the CLI and API. # Manager accounts Source: https://docs.erdo.ai/manager-accounts Operate many client organizations from one credential — provision an org per customer and run them all with a single manager key, instead of a pasted key per tenant. # Manager accounts A **manager account** lets one organization operate many client ("managed") organizations with a **single credential**. It's the pattern a portal uses to run an Erdo org per customer: instead of minting and pasting a separate API key for every tenant, you provision each client org under your manager org and operate them all with one **manager key**. This mirrors how a Google Ads MCC (manager account) operates many ad accounts, and how a SaaS control plane runs a tenant per customer. If you're building a product on top of Erdo that gives each of your customers their own isolated org, this is the surface you want. ## How it works Your **active org is the manager** — you must be an admin or owner of it. Two things hang off it: * **Managed orgs** — the client tenants you provision. Each is a full, isolated Erdo organization: its own datasets, pages, knowledge, and RBAC. Creating one records a management link (kept as an audit trail even after you stop managing) and its own identity inside the org, so downstream setup seeds with a real owner. * **The manager key** — one non-expiring API key, held by your manager org, that is a member of every org you manage. You operate a specific managed org by naming it in the request: pass its slug or id as the `X-Organization-ID` header (or `erdo --org ` on the CLI). The backend re-validates the manager's membership in that org on every request, so revoking management ends access immediately. Because access is enforced by membership plus the org header — the same mechanics as any Erdo token — there is no new auth path to reason about. A managed org's service usage never counts toward your manager org's billable seats, and never appears in its Team list. ## Provisioning a client org from a portal The typical portal flow, once per customer: Call `create managed organization` with the customer's name. You get back the new org's **slug** — the durable handle you store against that customer. Call `create manager key` to mint the single credential your portal holds. It's returned exactly once; store it as a secret. You only do this once (or again to rotate). Send the manager key with `X-Organization-ID` set to the customer's org slug to do anything inside that org — create datasets, build pages, run agents. The same key operates every customer. Call `revoke managed organization` with the slug to end management. Access stops immediately; the management link is retained as an audit record. Only an admin/owner of the manager org can create, revoke, or mint the key — the same authority check on every surface below. Listing is available to any member of the manager org. ## Adding people to a managed org A freshly provisioned client org has no human members — its only admin is the manager's service identity. To give a person access (your own operators, or the customer's team), add them by email from the manager surface: ```bash theme={null} curl -X POST https://api.erdo.ai/v1/managed-organizations/acme-corp-a1b2c3d4/members \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "ops@acme.com", "role": "admin" }' ``` The body takes `email` and an optional `role` — `member` (the default) or `admin`. `owner` is deliberately not grantable: a manager administers a client org, ownership stays with the client. * If an Erdo account already exists for the email, it becomes an active member immediately (`"invited": false`) and can open the org in the app right away. * If no account exists yet, a pending invitation is created and an invite email is sent (`"invited": true`); the membership activates when they sign up with that address. Re-adding an existing member is idempotent, and never demotes: requesting a lower role than the one they already hold reports the role in effect. ## Adopting an existing organization Provisioning covers orgs you create; **adoption** brings an org that *already exists* under your management — one created before your manager org existed, or previously run standalone. Because adoption hands a manager admin access to everything in the org, it requires **two-sided consent**: your manager key alone can never take over an org. Signed in to the org being handed over (owners only, in-app session — an API key cannot do this), the owner calls `POST /organization-adoption-invite`. The response contains a one-time token, shown exactly once — only its hash is stored. The owner can pin the manager allowed to redeem it by passing `{ "manager_org_slug": "your-manager-org" }`; the token expires after 7 days and works once. If the org is already managed, minting is refused — the current manager must revoke its link first (one manager per org). From the manager side — the same authority as every managed-org call (admin/owner of the manager org, or the manager key itself): ```bash theme={null} curl -X POST https://api.erdo.ai/v1/managed-organization-adoptions \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "token": "erdo_adopt_..." }' ``` Redeeming records the management link and seats the manager's service identity in the org, so the manager key can operate it immediately via `X-Organization-ID` — exactly like a provisioned org. The response is the same shape as create: the org's `slug`, `name`, and your management `role`. Adoption changes **who operates** the org, not what's in it: datasets, pages, knowledge, members, and ownership are untouched — the client's owner stays the owner. A used, expired, or unknown token reads uniformly as not found, so a leaked or guessed token confirms nothing. Redeeming a token for an org you already manage succeeds as a no-op; stopping management afterwards is the same `revoke managed organization` as ever, and the org can later be adopted again with a fresh token. ## Erdo platform Open **Settings → Manager Accounts** in the Erdo platform to create and view client organisations, stop managing an organisation, or create and rotate the manager key. The raw key is shown once: copy it into your server-side secret manager before leaving the page. This surface is available to ordinary Erdo organisations; creating, revoking, and rotating require an admin or owner. ## CLI ```bash theme={null} # provision a client org (prints its slug) erdo org managed create --name "Acme Corp" # list the orgs you manage — slug name role id erdo org managed list # mint (or rotate) the one manager key — shown ONCE erdo org managed key # operate a managed org with that key erdo --org acme-corp-a1b2c3d4 datasets list erdo --org acme-corp-a1b2c3d4 pages list # stop managing a client org (keeps an audit record) erdo org managed revoke acme-corp-a1b2c3d4 ``` The CLI authenticates with your Erdo token and active org (`erdo login`, `erdo org use`). In CI, set the manager key as `ERDO_API_KEY` and pin the target tenant with `ERDO_ORG` or `--org`. See the [CLI reference](/cli#manager-accounts). ## MCP tools | Tool | What it does | | ---------------------------------- | ---------------------------------------------------------------------------------- | | `erdo_list_managed_organizations` | List the client orgs your org manages — slug, name, management role, created time. | | `erdo_create_managed_organization` | Provision a new client org under your org (name, optional slug). Returns its slug. | | `erdo_revoke_managed_organization` | Stop managing a client org, named by slug. Idempotent; keeps the audit link. | Identity and RBAC ride the request context — a caller only ever acts as their own manager org, and managed orgs are referenced by **slug**, never a UUID. Manager keys are deliberately not minted by an MCP tool: a model-facing tool must not create a non-expiring credential. Create or rotate the key from Platform, the CLI, or `POST /v1/manager-key`, then use that credential to operate managed orgs through MCP. Member adds stay off the model-facing tool surface for the same reason — grant people access via `POST /v1/managed-organizations/:slug/members`. ## REST Base URL `https://api.erdo.ai`. Authenticate with `Authorization: Bearer ` and select the manager org with `X-Organization-ID` (for the lifecycle calls, that's your manager org; to operate a managed org, set it to that org's slug/id). | Method | Path | Purpose | | -------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/managed-organizations` | List the orgs you manage. | | `POST` | `/v1/managed-organizations` | Create a managed org (`{ "name": "...", "slug": "..." }`). | | `DELETE` | `/v1/managed-organizations/:slug` | Stop managing the org (idempotent). | | `POST` | `/v1/managed-organizations/:slug/members` | Add a person to the org by email (`{ "email": "...", "role": "member" \| "admin" }`). Invites when no account exists. | | `POST` | `/v1/managed-organization-adoptions` | Adopt an existing org by redeeming its owner's one-time token (`{ "token": "erdo_adopt_..." }`). | | `POST` | `/v1/manager-key` | Mint/rotate the manager key. The raw key is returned once. | The manager key returned by `POST /v1/manager-key` is a standard `erdo_api_*` bearer token pinned to your manager org — send it as `Authorization: Bearer ` with `X-Organization-ID: ` to act inside any org you manage. # MCP Server Source: https://docs.erdo.ai/mcp/overview Connect AI assistants and applications to your data via Erdo's MCP server # MCP Server Erdo exposes a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that lets AI assistants and applications query your datasets, ask data questions, manage conversations, and automate analysis — all using your existing Erdo permissions. Use it to: * **Connect AI assistants** like Claude Desktop, Cursor, or Windsurf to your data * **Build AI-powered apps** that query and visualize your data using any MCP client library * **Integrate with any LLM** via Vercel AI SDK, LangChain, or direct MCP client connections * **Automate recurring analysis** with heartbeat automations * **Manage knowledge** with memories and skills that persist across conversations ## Quick Start ### 1. Get an API Key Click your profile in the bottom-left corner of Erdo and go to **API Keys**. Create a new key and copy the token. ### 2. Connect to the MCP Server The MCP endpoint is `https://api.erdo.ai/mcp` using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). Any MCP-compatible client can connect. The organization is inferred from your API key automatically. Add to your `claude_desktop_config.json`: ```json theme={null} { "mcpServers": { "erdo": { "url": "https://api.erdo.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` ```bash theme={null} claude mcp add erdo \ --transport http \ --url https://api.erdo.ai/mcp \ --header "Authorization: Bearer YOUR_API_KEY" ``` Add to your `.cursor/mcp.json`: ```json theme={null} { "mcpServers": { "erdo": { "url": "https://api.erdo.ai/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` Connect from any MCP client library (TypeScript, Python, Go, etc.): ```typescript theme={null} import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; const client = new Client({ name: 'my-app', version: '1.0.0' }); await client.connect(new StreamableHTTPClientTransport( new URL('https://api.erdo.ai/mcp'), { requestInit: { headers: { 'Authorization': 'Bearer YOUR_API_KEY', }, }, }, )); // List available tools const { tools } = await client.listTools(); // Call a tool const result = await client.callTool({ name: 'erdo_list_datasets', arguments: {}, }); ``` To target a different organization that the API-key user belongs to, add `X-Organization-ID: `. To keep work inside one project, also add `X-Project-ID: `. The project must belong to the selected organization. Both headers are optional; omit `X-Project-ID` for the default **All projects** context. ### 3. Start Using It Once connected, the MCP client can discover and call Erdo tools. In AI assistants, try asking: * "List my datasets in Erdo" * "What columns does the sales dataset have?" * "How many orders were placed last month?" (uses the Data Question Answerer agent) * "Run a SQL query on my customers dataset to find the top 10 by revenue" * "Create a heartbeat that checks for anomalies in my revenue data every hour" ## Available Tools Erdo exposes MCP tools across data, threads, knowledge, KV stores (the shared key/value store, a.k.a. collections), artifacts, pages, agent runs, and automations. Three Platform-API surfaces have their own dedicated pages, each with the full MCP tool, REST, and CLI reference: [Evals](/evals), [Workstreams](/workstreams), and [Experiments](/experiments). ### Data Tools #### erdo\_list\_datasets List all datasets in your organization with name, type, description, and status. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ----------------------------------- | | `limit` | number | Optional. Max results (default 20). | #### erdo\_search\_datasets Search datasets by name or description. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ----------------------------------- | | `query` | string | Search text | | `limit` | number | Optional. Max results (default 20). | #### erdo\_get\_dataset\_schema Get detailed schema for a dataset including column names, types, statistics, and sample data. **Parameters:** | Parameter | Type | Description | | ------------ | ------ | ------------ | | `dataset_id` | string | Dataset UUID | #### erdo\_gather\_dataset\_context Get detailed context for multiple datasets at once — schemas, column types, statistics, descriptions, and sample data. Useful for understanding your data landscape before asking questions. **Parameters:** | Parameter | Type | Description | | --------------- | --------- | ----------------------------------------------------------- | | `dataset_slugs` | string\[] | Optional. Specific dataset IDs or slugs. Empty returns all. | | `limit` | number | Optional. Max datasets to return (default 10). | #### erdo\_fetch\_dataset\_contents Fetch raw contents of a dataset. Returns rows and columns directly without requiring a SQL query. Useful for exploring small datasets or getting a quick preview. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | ----------------------------- | | `dataset_slug` | string | Dataset UUID or slug | | `limit` | number | Optional. Max rows to return. | #### erdo\_run\_query Run a raw SQL query directly against a dataset and return rows and columns. Use this when you already know the exact SQL you want to run. The SQL dialect depends on the dataset's storage backend (PostgreSQL, ClickHouse, or DuckDB for file-based datasets). **Parameters:** | Parameter | Type | Description | | -------------- | ------ | ------------------------------------------- | | `dataset_slug` | string | Dataset UUID or slug to query | | `query` | string | SQL query to execute | | `limit` | number | Optional. Max rows to return (default 100). | #### erdo\_query\_data Query a dataset using natural language. Describe what data you want and Erdo will generate and execute the SQL query for you. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | ------------------------------------------------------------------ | | `question` | string | Natural language question, e.g. "show top 10 customers by revenue" | | `dataset_slug` | string | Dataset UUID or slug to query | **Returns:** the generated `sql`, the result values as `columns` + `rows` (the same tabular shape `erdo_fetch_dataset_contents` returns), `row_count` — how many rows the query matched, which can exceed the rows returned since those are capped at 1000 — and `output`, the result rendered to read. `applied_filters` names any of the dataset's saved filters the read ran under, so a count is never reported as the whole truth when it excludes something. #### erdo\_ask\_data\_question Ask a natural language question about your data. This invokes Erdo's Data Question Answerer agent, which analyzes datasets, writes and executes code, and returns a text answer. To visualize results, use `erdo_render_chart` or `erdo_render_table`. This tool can take 30 seconds to 2 minutes for complex questions, as it runs a full AI analysis pipeline. **Parameters:** | Parameter | Type | Description | | --------------- | --------- | -------------------------------------------------- | | `question` | string | The data question to answer | | `dataset_slugs` | string\[] | Optional. Dataset slugs to scope the question to. | | `timezone` | string | Optional. User timezone (e.g. `America/New_York`). | **Returns:** A thread ID (for follow-up in the Erdo UI) and the agent's text answer. #### erdo\_render\_chart Render a data visualization chart. Supports bar, line, pie, histogram, and scatter chart types. The chart fetches data directly from the dataset — no embedded data needed. **Parameters:** | Parameter | Type | Description | | ---------------- | --------- | ------------------------------------------------------------------------- | | `chart_type` | string | Chart type: `bar`, `line`, `pie`, `histogram`, or `scatter` | | `chart_title` | string | Title for the chart | | `x_axis` | object | X-axis configuration (label, key, format, value\_type) | | `y_axes` | object\[] | Y-axis configurations | | `series` | object\[] | Data series, each with `dataset_slug`, `key`, `sql_query`, `resource_key` | | `data_reduction` | object | Data reduction strategy (none, sample, aggregate, bin) | | `stacked` | boolean | Whether to stack bars (for bar charts) | | `sort` | object\[] | Sort conditions | #### erdo\_render\_table Render a data table. The table fetches data directly from the dataset. **Parameters:** | Parameter | Type | Description | | -------------- | -------------- | ----------------------------------------------------------- | | `table_title` | string | Title for the table | | `dataset_slug` | string | Dataset slug | | `columns` | object\[] | Column definitions (column\_name, key, format, value\_type) | | `sql_query` | string \| null | Optional SQL query to filter/transform data | | `resource_key` | string \| null | Required for file datasets (CSV/Excel) | #### erdo\_create\_dataset Create a new empty dataset. After creation, use `erdo_write_rows` to add data. The dataset uses your organization's default storage backend. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | ------------------------------------------------------------ | | `name` | string | Name for the dataset | | `description` | string | Optional. Description. | | `instructions` | string | Optional. Instructions for AI agents analyzing this dataset. | **Returns:** The created dataset with `id`, `slug`, `name`, `type`, and `status`. #### erdo\_upload\_dataset\_file Upload a file (CSV, TSV, Excel, JSON, JSONL, PDF, DOCX, TXT, Markdown, ...) and create a dataset from it in one call. The schema is extracted before the tool returns, so the dataset is immediately queryable. Prefer this over `erdo_create_dataset` + `erdo_write_rows` when the data already exists as a file. **Parameters:** | Parameter | Type | Description | | ---------------- | ------ | ---------------------------------------------------------------------------------------------- | | `filename` | string | Filename with extension — drives type detection | | `content_base64` | string | The raw file bytes as standard base64 (max 20 MB decoded; larger files go through the web app) | | `name` | string | Optional. Display name; defaults to the filename. | | `description` | string | Optional. Description shown to agents analyzing the dataset. | **Returns:** `{ dataset_id, slug, name, ready }` — `ready` is `false` when the file stored but its schema could not be extracted (not yet queryable). #### erdo\_delete\_dataset Delete a dataset and all its data. This is permanent. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | ------------------------------ | | `dataset_slug` | string | Dataset UUID or slug to delete | #### erdo\_write\_rows Write or upsert rows to a dataset. For database-backed datasets (Postgres, ClickHouse), set `key_column` to upsert — matching rows are updated, new rows are inserted. For file datasets (CSV), rows are always appended. **Parameters:** | Parameter | Type | Description | | -------------- | --------- | ------------------------------------------------- | | `dataset_slug` | string | Dataset slug to write to | | `rows` | object\[] | Array of row objects (column name → value) | | `key_column` | string | Optional. Column for upsert (update on conflict). | **Returns:** `{ rows_affected: number, rows_inserted: number, rows_updated: number }` — a keyed write is an upsert, so the split says how many rows it created and how many it replaced. Without a `key_column` the write appends and all of it is inserted. #### erdo\_delete\_rows Delete rows from a dataset. Works for both file (CSV) and database-backed datasets. **Parameters:** | Parameter | Type | Description | | -------------- | --------- | ----------------------------------------------------------- | | `dataset_slug` | string | Dataset slug to delete from | | `key_column` | string | Optional. Column to match keys against. | | `keys` | string\[] | Optional. Key values to delete. If empty, deletes all rows. | **Returns:** `{ rows_affected: number }` #### erdo\_update\_dataset\_schema Update a dataset's schema: add, remove, rename columns, or change column types. Operations are applied atomically — if any fails, none are applied. Supported for CSV file datasets only. After changes, analysis is automatically refreshed. **Parameters:** | Parameter | Type | Description | | -------------- | --------- | ------------------------------------- | | `dataset_slug` | string | Dataset UUID or slug | | `operations` | object\[] | Schema operations to apply atomically | **Operation object:** | Field | Type | Description | | ------------- | ------ | --------------------------------------------------------------------------------------------------- | | `type` | string | `add_column`, `remove_column`, `rename_column`, or `alter_column_type` | | `column` | string | Target column name | | `new_name` | string | New name (for `rename_column` only) | | `column_type` | string | Type hint: `text`, `integer`, `float`, `date`, `boolean` (for `add_column` and `alter_column_type`) | **Returns:** `{ columns_added, columns_removed, columns_renamed, columns_retyped, current_columns }` ### Integration Tools Connect third-party apps and data sources without leaving your AI assistant. What decides the flow is whether the caller already holds the credential: databases, API keys, and service accounts connect in one call, and so do SaaS apps whose auth type is `keys`. OAuth apps are the exception — the provider mints their credentials during the authorization, so they return a `connect_url` for the user to open in a browser and `erdo_check_integration_connection` completes the loop. See [Connecting integrations](/integrations) for the flows end to end. #### erdo\_list\_integrations List the integrations connected in your organization with app identifier, name, status, and auth type. #### erdo\_search\_integration\_apps Search apps that can be connected — native integrations (databases, warehouses, APIs) plus thousands of SaaS apps. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ---------------------------------------------------------------------------------------------------- | | `query` | string | Optional search term, e.g. `slack` or `postgres`. Empty lists native integrations plus popular apps. | **Returns:** each app's identifier (pass it to `erdo_connect_integration`), name, `auth_types`, and `source` (`native` or `pipedream`). #### erdo\_connect\_integration Connect an app. With `credentials`, a native credential-based app is created, verified against the provider, and activated in this one call; a SaaS `keys` app has its credentials stored and is active immediately, though nothing calls the vendor, so they are not validated until the first action run. Without credentials, OAuth apps return a `connect_url` for the user to authorize in a browser. **Parameters:** | Parameter | Type | Description | | ------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `app` | string | App identifier from `erdo_search_integration_apps` | | `name` | string | Optional display name for the connection | | `credentials` | object | Credentials keyed by the field names the app declares — database connection fields, API keys, service-account JSON. Works for native integrations and for SaaS apps whose auth type is `keys`. Passing them to an OAuth app is **rejected**: the provider issues those during authorization, so connect without them and use the returned `connect_url`. | | `scopes` | string\[] | Optional scopes to grant; defaults to all the app supports | | `return_url` | string | Optional absolute `http(s)` URL to send the user's browser to after they authorize an OAuth app at `connect_url`. Defaults to Erdo's data page — pass your own page when embedding the connect flow in your product so the user lands back on it. Ignored for credential-based apps that connect in one call. | **Returns:** `{ app, integration_id, status, connect_url?, next_step }` — `status` is `active` when connected immediately, `pending` when browser authorization is needed. #### erdo\_check\_integration\_connection Check whether an app is connected. For browser-authorized apps this also completes any connection the user finished since `erdo_connect_integration` was called — poll it after the user opens the `connect_url`. An app that has never been connected answers `connected: false` with an empty `integrations` list; that is an ordinary answer, not an error. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ------------------------------------------------------- | | `app` | string | The app identifier passed to `erdo_connect_integration` | #### erdo\_discover\_integration\_tables Discover what a connected database integration exposes. Without `schema_name`, lists the selectable schemas (all SQL databases and warehouses); with it, lists that schema's tables with columns — supported for SQL databases (Postgres, MySQL, and compatible); warehouses (BigQuery, Snowflake, ClickHouse) list schemas but not per-table columns here. Use before `erdo_create_integration_dataset`. **Parameters:** | Parameter | Type | Description | | ------------- | ------ | ------------------------------------------------------------------------- | | `integration` | string | App key (e.g. `postgres`) or integration id from `erdo_list_integrations` | | `schema_name` | string | Optional. Schema to list tables for. | #### erdo\_create\_integration\_dataset Create a dataset backed by any connected integration that exposes queryable data. Database and warehouse integrations may query live; sync-capable API integrations automatically materialize their provider resources through Erdo's data platform. Integrations that expose selectable scopes require a `schemas` selection (some allow only one); integrations with neither a direct query path nor dataset sync are rejected. Resource/column discovery runs in the background and the schema appears after discovery or the first sync. **Parameters:** | Parameter | Type | Description | | ------------- | --------- | ------------------------------------------- | | `integration` | string | App key or integration id | | `name` | string | Display name for the dataset | | `description` | string | Optional. Description shown to agents. | | `schemas` | string\[] | Selectable schema names (or ids) to include | **Returns:** `{ dataset_id, slug, name, status }` #### erdo\_configure\_integration\_dataset Update an existing integration dataset using provider-declared segments rather than provider-specific campaign, account, or schema endpoints. Optionally enables the canonical data-platform sync; no snapshot dataset is created. **Parameters:** | Parameter | Type | Description | | ------------- | --------- | --------------------------------------------------------------------- | | `dataset_id` | string | Existing integration dataset id | | `segments` | string\[] | Segment names or ids from `erdo_discover_integration_tables` | | `enable_sync` | boolean | Optional. Enable canonical data-platform sync after saving the scope. | **Returns:** `{ dataset_id, status, sync_enabled, segments }` ### Thread & Conversation Tools #### erdo\_list\_threads List conversation threads with name, creation date, and visibility. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ----------------------------------- | | `limit` | number | Optional. Max results (default 20). | #### erdo\_get\_thread\_messages Get all messages from a conversation thread including content and metadata. Content items preserve `ui_content_type` and `created_by_invocation_id` so MCP clients can render the same tool activity and generated UI as Erdo. **Parameters:** | Parameter | Type | Description | | ----------- | ------ | ----------- | | `thread_id` | string | Thread UUID | #### erdo\_create\_thread Create a new conversation thread, optionally with datasets attached. **Parameters:** | Parameter | Type | Description | | ------------- | --------- | ---------------------------------- | | `name` | string | Optional. Thread name. | | `dataset_ids` | string\[] | Optional. Dataset UUIDs to attach. | #### erdo\_send\_message Send a message to a thread and get an AI-generated response. The message is processed by an AI agent that can analyze data, write SQL, generate charts, and more. This tool can take 30 seconds to 2 minutes depending on the question complexity. **Parameters:** | Parameter | Type | Description | | ----------- | ------ | ------------------------------------------------------------------------------------------------------------- | | `thread_id` | string | Thread UUID | | `message` | string | The message to send | | `agent_key` | string | Optional. Agent to use (default: `erdo.data-question-answerer`). Use `erdo.data-analyst` for deeper analysis. | | `timezone` | string | Optional. User timezone (e.g. `America/New_York`). | **Returns:** The thread ID, message ID, status, and the agent's answer. ### Knowledge Tools [Knowledge](/knowledge) records store durable context (`snippet`) or reusable instructions (`skill`) that Erdo's agents read in future conversations. Use these to teach the workforce your definitions, rules, and procedures. #### erdo\_create\_knowledge Create a new Knowledge record or skill. **Parameters:** | Parameter | Type | Description | | ------------- | --------- | -------------------------------------------------------------------------------- | | `title` | string | Short title for the record | | `content` | string | The main content or instructions | | `description` | string | Brief description of what this record does | | `type` | string | `snippet` (knowledge) or `skill` (reusable instructions). Defaults to `snippet`. | | `category` | string | Optional. Category (e.g. "Data Analysis", "SQL"). | | `tags` | string\[] | Optional. Tags for organization. | | `dataset_ids` | string\[] | Optional. Associated dataset UUIDs. | #### erdo\_search\_knowledge Search Knowledge records and skills by semantic similarity. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ------------------------------------------------- | | `query` | string | Search text | | `limit` | number | Optional. Max results (default 10, hard cap 100). | #### erdo\_list\_knowledge List Knowledge records and skills with optional filtering. **Parameters:** | Parameter | Type | Description | | ---------- | ------ | ------------------------------------------------- | | `type` | string | Optional. Filter by `snippet` or `skill`. | | `category` | string | Optional. Filter by category. | | `limit` | number | Optional. Max results (default 20, hard cap 100). | | `offset` | number | Optional. Pagination offset. | #### erdo\_delete\_knowledge Delete a Knowledge object by ID (soft delete — can be recovered). **Parameters:** | Parameter | Type | Description | | --------------------- | ------ | --------------------- | | `knowledge_object_id` | string | Knowledge object UUID | #### erdo\_set\_knowledge\_visibility Set a Knowledge object's [visibility](/knowledge#visibility--workspace-vs-public): `workspace` keeps it organization-internal (the default), `public` opts it into anonymous external surfaces such as the [website voice widget](/voice-widget) — a draft is approved in the same step and goes live immediately. Only make non-sensitive, customer-facing facts public. **Parameters:** | Parameter | Type | Description | | --------------------- | ------ | ----------------------- | | `knowledge_object_id` | string | Knowledge object UUID | | `visibility` | string | `workspace` or `public` | ### KV (Collection) Tools KV stores are Erdo's shared **key/value store** — named, org-level stores (also called *collections*) holding config and values (pricing, targets, brand tokens) that stay consistent everywhere. The same stores are read by [pages](/apps/build-apps#per-page-state-kv) (`erdo.kv`, aliased `erdo.collections`), referenced from [Knowledge](/knowledge#shared-values-with-collections) bodies as `{{slug.key}}`, and reachable from the agent runtime — one store, one source of truth. Values are any JSON type. Pages also get a private, lazily-provisioned KV store of their own; these tools operate on **named** stores shared across pages and knowledge. #### erdo\_list\_kv\_stores List the organization's named KV stores with their slug and item count. **Parameters:** none. #### erdo\_get\_kv\_item Read one value from a named KV store by key. Use for shared config/metrics that should be consistent everywhere rather than hardcoding values. **Parameters:** | Parameter | Type | Description | | --------- | ------ | -------------------- | | `kv_slug` | string | Slug of the KV store | | `key` | string | Item key to read | **Returns:** `{ key, value, found }`. #### erdo\_set\_kv\_item Write one value (any JSON type) to a named KV store by key — the canonical value everything references. Requires write (EDIT) access on the store. Reference it from Knowledge prose as `{{kv_slug.key}}` so it stays current everywhere. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ---------------------------------------------------------- | | `kv_slug` | string | Slug of the KV store to write to | | `key` | string | Item key to set | | `value` | any | Value to store — string, number, boolean, object, or array | #### erdo\_create\_kv\_store Create a named KV store — a shared key/value store for config and values referenced across pages and knowledge. **Parameters:** | Parameter | Type | Description | | --------- | ------ | -------------------------------------------------------------- | | `slug` | string | Slug for the new KV store (lowercase letters, digits, hyphens) | #### erdo\_delete\_kv\_item Delete one value from a named KV store by key. Requires write (EDIT) access on the store. Deleting a key that doesn't exist is a no-op. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ----------------------------------- | | `kv_slug` | string | Slug of the KV store to delete from | | `key` | string | Item key to delete | ### Artifact Tools Artifacts are AI-generated outputs from agent runs and automations — insights, charts, metrics, alerts, and suggestions. #### erdo\_list\_artifacts List artifacts with optional type filtering. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ---------------------------------------------------------------------------------- | | `type` | string | Optional. Filter by: `insight`, `chart`, `metric`, `alert`, `table`, `suggestion`. | | `limit` | number | Optional. Max results (default 20). | | `offset` | number | Optional. Pagination offset. | #### erdo\_get\_artifact Get full details of a specific artifact including its content, metadata, and severity. **Parameters:** | Parameter | Type | Description | | ------------- | ------ | ------------- | | `artifact_id` | string | Artifact UUID | ### Media Tools #### erdo\_screenshot Capture a screenshot of a web page and get back a **signed, time-limited download URL** for the PNG, plus its dimensions. Use this when you need the image *file*. By default it renders a **public** URL (a marketing site, a published Erdo page at `https://pages.erdo.ai/p/{id}`, a competitor page). Pass `instructions` to capture a page that requires **logging in first** — the capture runs asynchronously (returns a `job_id` with `status: "processing"`); poll `erdo_screenshot_result` with that `job_id` until it's `done`. **Parameters:** | Parameter | Type | Description | | --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | string | http(s) URL to capture. | | `instructions` | string | Optional. Steps to perform before the shot (e.g. sign in). Triggers an async capture. | | `full_page` | boolean | Optional. Capture the whole scrollable page (default true); set `false` for just the viewport. | | `width` | number | Optional. Viewport width in px (default 1440). Use \~390 for a phone. | | `height` | number | Optional. Viewport height in px (default 900). | | `color_scheme` | string | Optional. `light`/`dark` — emulates the OS theme for **public** pages that follow system theme. Ignored for signed-in captures (use `local_storage`). | | `device_scale_factor` | number | Optional. Pixel density 1–3 (default 2). | | `local_storage` | object | Optional. `localStorage` entries set before the page loads, then reloaded. The reliable way to theme a **signed-in** capture — pass `{"theme": "dark"}` to render the app in dark mode. | Returns `signed_url`, `bucket_key`, `media_type`, `width`, `height`, and `expires_at` (or `job_id` + `status` for an instructed capture — fetch the result with `erdo_screenshot_result`). #### erdo\_screenshot\_result Poll an instructed (async) capture started by `erdo_screenshot`. Pass the `job_id` it returned. **Parameters:** | Parameter | Type | Description | | --------- | ------ | -------------------------------------------------------------- | | `job_id` | string | The `job_id` returned by an instructed `erdo_screenshot` call. | Returns the same shape as `erdo_screenshot` once `status` is `done` (`signed_url`, `bucket_key`, `media_type`, dimensions, `expires_at`); `processing` means try again shortly, `error` includes an `error` message. #### erdo\_upload\_image Upload an image so it can be attached to a data question via `erdo_ask_data_question`. Pass the raw bytes as standard base64. Accepts PNG, JPEG, WEBP, or GIF, up to 5 MB. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | -------------------------------------------------------- | | `image_base64` | string | Standard base64-encoded image bytes. | | `media_type` | string | `image/png`, `image/jpeg`, `image/webp`, or `image/gif`. | Returns `{ bucket_key, media_type, width, height }` — pass `bucket_key` in the `images` array of `erdo_ask_data_question`. ### Agent Run Tools Agent runs are the record of what agents have done — the runs behind `erdo_ask_data_question`, `erdo_send_message`, and automations. #### erdo\_list\_agent\_runs List agent runs, filterable by agent or thread or status. **Parameters:** | Parameter | Type | Description | | ----------- | ------ | ---------------------------------------------------------------------------------------------- | | `agent_key` | string | Optional. Filter by agent, e.g. `erdo.artifact-builder` (mutually exclusive with `thread_id`). | | `thread_id` | string | Optional. Filter to one thread (mutually exclusive with `agent_key`). | | `limit` | number | Optional. Max runs (default 50). | | `offset` | number | Optional. Pagination offset. | #### erdo\_get\_agent\_run Get one agent run: status, agent, output, and trace metadata. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ------------------------------------------- | | `run_id` | string | Agent run id (from `erdo_list_agent_runs`). | ### Approval Tools Approval requests are actions an agent paused on, awaiting a human decision. See [Approvals](/approvals). #### erdo\_list\_approvals List approval requests, filterable by status. **Parameters:** | Parameter | Type | Description | | ----------------- | ------ | ---------------------------------------------------------------------------------------- | | `status` | string | Optional. Filter by status: `pending`, `approved`, `rejected`, `expired` (default: all). | | `limit` | number | Optional. Max requests (default 50). | | `offset` | number | Optional. Pagination offset. | | `workstream_slug` | string | Optional. Return only approvals attached to this exact Workstream or Strategy. | #### erdo\_decide\_approval Approve or reject a pending approval request so the paused agent run can continue (or be rejected). **Parameters:** | Parameter | Type | Description | | ---------- | ------ | -------------------------------------------------------------------------------------------------------- | | `id` | string | Approval request id (from `erdo_list_approvals`). | | `decision` | string | `approved` or `rejected`. | | `scope` | string | Optional. `once` (default), `always_this_job`, `always_this_workstream`, `always_org`, or `always_user`. | ### Decision Tools The decision record: what your organization committed to, whether the change actually happened, and what the evidence said afterwards. See [Decisions](/decisions). #### erdo\_list\_decisions Search the decision record. Filters compose with AND, and an unrecognised value in a closed vocabulary is refused rather than ignored. **Parameters:** | Parameter | Type | Description | | ------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------- | | `workstream_slug` | string | Optional. Only decisions filed under this Workstream or Strategy. | | `source` | string | Optional. Producer family: `approval`, `escalation`, `engine_gate`, `allocator`, `experiment`, `workstream_commitment`. | | `decision_class` | string | Optional. The provider-agnostic kind, e.g. `paid_media.ad_group.pause`. | | `subject_kind` / `subject_ref` | string | Optional. Only decisions about one subject — the per-campaign or per-page record. | | `status` | string | Optional. `proposed`, `authorized`, `executing`, `effective`, `measuring`, `settled`, `rejected`, `failed`, `censored`. | | `applicability` | string | Optional. `standing` (a course that outlives the work it authorized) or `one_shot`. | | `outcome` | string | Optional. Only decisions with a settled effect that came back `met`, `not_met`, or `inconclusive`. | | `limit` / `offset` | number | Optional. Paging (default 50, max 200). | #### erdo\_get\_decision Read one decision in full: the commitment and its rationale, who decided it and under what authority, every exact action with how it ended, every declared effect with the evidence that settled it, and supersession in both directions. **Parameters:** | Parameter | Type | Description | | --------------- | ------ | --------------------------------------------------------------------------- | | `decision_slug` | string | Slug of the decision (from `erdo_list_decisions` or the Workstream ledger). | #### erdo\_decision\_scorecard Raw aggregates with their denominators, stratified by decision class and evidence kind. No eligibility verdict, and no single pooled "worked rate" — deterministic confirmation, experimental results and observational movement are counted and labelled separately. **Parameters:** | Parameter | Type | Description | | ----------------- | ------ | -------------------------------------------------------------------------------------------------- | | `workstream_slug` | string | Optional. Score only this Workstream or Strategy's decisions. | | `source` | string | Optional. Score only one producer family. | | `decision_class` | string | Optional. Score only one decision class. | | `since` / `until` | string | Optional. RFC3339 bounds on when the decision was recorded (`since` inclusive, `until` exclusive). | ### Page Deploy Tools Deploy HTML pages/apps to Erdo from any coding agent. Pages run in the Erdo page runtime: `window.erdo` gives them governed access to the datasets you grant, and the default `react-tailwind` runtime provides React 18, Tailwind, and the Erdo UI components (`DatasetChart`, `DatasetTable`, ...). A deploy with validation errors still saves and reports them — fix with `erdo_update_page` and iterate until clean. #### erdo\_deploy\_page Deploy a new page and get back its URL plus structured validation results. Pages are private by default. **Parameters:** | Parameter | Type | Description | | ------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------- | | `title` | string | Page title shown in Erdo. | | `html` | string | HTML content — full document or fragment. With `react-tailwind`, include a root element and put React code in `js`. | | `css` | string | Optional stylesheet. | | `js` | string | Optional JavaScript/JSX. | | `runtime` | string | Optional. `react-tailwind` (default) or `none`. | | `dataset_slugs` | string\[] | Optional. Datasets the page **reads** via `window.erdo.queryDataset` — granted read access on each. | | `writable_dataset_slugs` | string\[] | Optional. Datasets the page may **append to** via `window.erdo.insertRows` — granted write access on each (you must hold edit). | | `kv_slugs` | string\[] | Optional. Named KV stores (collections) the page **reads** via `erdo.kv.get/list` — granted read access on each. | | `writable_kv_slugs` | string\[] | Optional. Named KV stores the page may **write** via `erdo.kv.set/delete` — granted edit access on each. | | `public` | boolean | Optional. Make the page publicly viewable at its share URL immediately (default: private). | **Returns:** `{ id, title, url, public_url?, public, thread_id, validation }`. `url` is the authenticated editor view; `public_url` is the visitor-facing share link (present while public). Use the returned URLs verbatim — hosts differ per environment. `dataset_slugs` / `kv_slugs` grant **read**; `writable_dataset_slugs` / `writable_kv_slugs` grant **write**. `erdo.insertRows` and writes to a named KV store only work when the matching writable grant was declared at deploy — otherwise they return a permission error. The page's own private KV store and `submitEvent` (pipelines) need no write grant. See [Build Apps](/apps/build-apps#auth--sharing). #### erdo\_update\_page Update a deployed page. Provided fields are merged — send only `js` to fix a script without resending `html`/`css`. Returns fresh validation results. **Parameters:** | Parameter | Type | Description | | ------------------------ | --------- | ------------------------------------------------------------- | | `page_id` | string | Page ID from `erdo_deploy_page` or `erdo_list_artifacts`. | | `title` | string | Optional. New title. | | `html` | string | Optional. New HTML. | | `css` | string | Optional. New stylesheet. | | `js` | string | Optional. New JavaScript/JSX. | | `dataset_slugs` | string\[] | Optional. Replacement read-dataset list. | | `writable_dataset_slugs` | string\[] | Optional. Replacement writable-dataset list (`[]` clears it). | | `kv_slugs` | string\[] | Optional. Replacement read KV-store list. | | `writable_kv_slugs` | string\[] | Optional. Replacement writable KV-store list. | | `public` | boolean | Optional. `true` shares publicly, `false` reverts to private. | #### erdo\_validate\_page Dry-run validation without deploying anything: HTML structure, JS/JSX syntax and runtime smoke checks, `window.erdo` usage, and dataset-slug references. Real data queries are additionally probed on actual deploy/update. **Parameters:** same content fields as `erdo_deploy_page` (`html`, `css`, `js`, `runtime`, `dataset_slugs`). ### Automation Tools Heartbeats are recurring agents that analyze your data on a schedule and generate insights, alerts, and reports. #### erdo\_list\_heartbeats List heartbeat automations with their schedule, state, and latest execution status. **Parameters:** | Parameter | Type | Description | | --------- | ------ | ----------------------------------- | | `limit` | number | Optional. Max results (default 20). | | `offset` | number | Optional. Pagination offset. | #### erdo\_create\_heartbeat Create a recurring automation that analyzes your data on a schedule. **Parameters:** | Parameter | Type | Description | | --------------------- | --------- | ----------------------------------------------------------------- | | `name` | string | Name for the automation | | `instructions` | string | Instructions for the agent to follow on each run | | `interval_minutes` | number | How often to run (minimum 5 minutes) | | `description` | string | Optional. What this automation does. | | `timezone` | string | Optional. Timezone for scheduling (default UTC). | | `active_window_start` | string | Optional. Only run after this time (24h format, e.g. `09:00`). | | `active_window_end` | string | Optional. Only run before this time (e.g. `18:00`). | | `active_days` | number\[] | Optional. Days of week to run (0=Sun..6=Sat). Omit for every day. | | `dataset_ids` | string\[] | Optional. Dataset UUIDs to analyze. | | `effort` | string | Optional. Agent effort: `low`, `medium`, or `high`. | #### erdo\_run\_heartbeat Manually trigger a heartbeat to run immediately, outside its normal schedule. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | -------------- | | `heartbeat_id` | string | Heartbeat UUID | #### erdo\_set\_heartbeat\_state Enable or disable a heartbeat automation. Set it to `disabled` to pause a misbehaving automation so it stops running, or `active` to resume it. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | ---------------------- | | `heartbeat_id` | string | Heartbeat UUID | | `state` | string | `active` or `disabled` | #### erdo\_list\_heartbeat\_executions List recent executions of a heartbeat with status, timing, and associated thread. **Parameters:** | Parameter | Type | Description | | -------------- | ------ | ----------------------------------- | | `heartbeat_id` | string | Heartbeat UUID | | `limit` | number | Optional. Max results (default 10). | ## REST API All MCP tools are also available as REST endpoints for direct HTTP integration. Use these when you don't need the full MCP protocol (e.g. from LangChain, Vercel AI SDK, or custom scripts). **Base URL:** `https://api.erdo.ai` **Authentication:** Pass `Authorization: Bearer YOUR_API_KEY` header. The organization is inferred from your API key. ### Endpoint Reference #### Data Endpoints | MCP Tool | REST Endpoint | Method | | ----------------------------- | ----------------------------- | ------ | | `erdo_list_datasets` | `/v1/datasets` | GET | | `erdo_search_datasets` | `/v1/datasets-search` | GET | | `erdo_get_dataset_schema` | `/v1/datasets/:id/schema` | GET | | `erdo_gather_dataset_context` | `/v1/dataset-context` | GET | | `erdo_fetch_dataset_contents` | `/v1/datasets/:slug/fetch` | POST | | `erdo_run_query` | `/v1/datasets/:slug/query` | POST | | `erdo_query_data` | `/v1/datasets/:slug/query-nl` | POST | | `erdo_ask_data_question` | `/v1/ask` | POST | | `erdo_render_chart` | `/v1/render/chart` | POST | | `erdo_render_table` | `/v1/render/table` | POST | | `erdo_screenshot` | `/v1/screenshot` | POST | | `erdo_screenshot_result` | `/v1/screenshot/result` | POST | | `erdo_upload_image` | `/v1/images/upload` | POST | | `erdo_create_dataset` | `/v1/datasets-create` | POST | | `erdo_upload_dataset_file` | `/v1/datasets-upload` | POST | | `erdo_delete_dataset` | `/v1/datasets/:slug` | DELETE | | `erdo_write_rows` | `/v1/datasets/:slug/rows` | POST | | `erdo_delete_rows` | `/v1/datasets/:slug/rows` | DELETE | | `erdo_update_dataset_schema` | `/v1/datasets/:slug/schema` | POST | #### Integration Endpoints | MCP Tool | REST Endpoint | Method | | ------------------------------------ | ------------------------------------------------ | ------ | | `erdo_list_integrations` | `/v1/integrations` | GET | | `erdo_search_integration_apps` | `/v1/integration-apps` | GET | | `erdo_connect_integration` | `/v1/integrations-connect` | POST | | `erdo_check_integration_connection` | `/v1/integrations-connect/:app` | GET | | `erdo_discover_integration_tables` | `/v1/integrations/:integration/tables` | GET | | `erdo_create_integration_dataset` | `/v1/integration-datasets` | POST | | `erdo_configure_integration_dataset` | `/v1/integration-datasets/:dataset_id/configure` | POST | #### Thread & Conversation Endpoints | MCP Tool | REST Endpoint | Method | | -------------------------- | -------------------------- | ------ | | `erdo_list_threads` | `/v1/threads` | GET | | `erdo_get_thread_messages` | `/v1/threads/:id/messages` | GET | | `erdo_create_thread` | `/v1/threads-create` | POST | | `erdo_send_message` | `/v1/threads/:id/send` | POST | `erdo_send_message` accepts optional `context` for application state that should guide this turn without being stored as the visible user message. Use `message` for the operator's exact words and `context` for the current page, selection, or other structured facts. The thread list includes the creator's identity and the creation source; unnamed threads are titled from their first user message. Sent messages retain the authenticated user's authorship in the transcript. Message reads include that user's canonical ID, name, and email when the user is still in the organization. #### Knowledge Endpoints | MCP Tool | REST Endpoint | Method | | ------------------------------- | ------------------------------ | ------ | | `erdo_create_knowledge` | `/v1/knowledge` | POST | | `erdo_search_knowledge` | `/v1/knowledge-search` | GET | | `erdo_list_knowledge` | `/v1/knowledge` | GET | | `erdo_delete_knowledge` | `/v1/knowledge/:id` | DELETE | | `erdo_set_knowledge_visibility` | `/v1/knowledge/:id/visibility` | PATCH | #### KV (Collection) Endpoints | MCP Tool | REST Endpoint | Method | | ---------------------- | ------------------------- | ------ | | `erdo_list_kv_stores` | `/v1/kv` | GET | | `erdo_create_kv_store` | `/v1/kv` | POST | | `erdo_get_kv_item` | `/v1/kv/:slug/items/:key` | GET | | `erdo_set_kv_item` | `/v1/kv/:slug/items/:key` | PUT | | `erdo_delete_kv_item` | `/v1/kv/:slug/items/:key` | DELETE | #### Artifact Endpoints | MCP Tool | REST Endpoint | Method | | --------------------- | ------------------- | ------ | | `erdo_list_artifacts` | `/v1/artifacts` | GET | | `erdo_get_artifact` | `/v1/artifacts/:id` | GET | #### Page Deploy Endpoints | MCP Tool | REST Endpoint | Method | | -------------------- | -------------------- | ------ | | `erdo_deploy_page` | `/v1/pages` | POST | | `erdo_update_page` | `/v1/pages/:id` | PUT | | `erdo_validate_page` | `/v1/pages/validate` | POST | #### Automation Endpoints | MCP Tool | REST Endpoint | Method | | -------------------------------- | ------------------------------- | ------ | | `erdo_list_heartbeats` | `/v1/heartbeats` | GET | | `erdo_create_heartbeat` | `/v1/heartbeats` | POST | | `erdo_run_heartbeat` | `/v1/heartbeats/:id/run` | POST | | `erdo_set_heartbeat_state` | `/v1/heartbeats/:id/state` | POST | | `erdo_list_heartbeat_executions` | `/v1/heartbeats/:id/executions` | GET | #### Agent Run Endpoints | MCP Tool | REST Endpoint | Method | | ---------------------- | ----------------- | ------ | | `erdo_list_agent_runs` | `/v1/runs` | GET | | `erdo_get_agent_run` | `/v1/runs/:runID` | GET | #### Approval Endpoints | MCP Tool | REST Endpoint | Method | | ---------------------- | -------------------------- | ------ | | `erdo_list_approvals` | `/v1/approvals` | GET | | `erdo_decide_approval` | `/v1/approvals/:id/decide` | POST | #### Decision Endpoints | MCP Tool | REST Endpoint | Method | | ------------------------- | ------------------------- | ------ | | `erdo_list_decisions` | `/v1/decisions` | GET | | `erdo_get_decision` | `/v1/decisions/:slug` | GET | | `erdo_decision_scorecard` | `/v1/decisions-scorecard` | GET | #### Manager Account Endpoints Operate many client orgs from one credential — see [Manager accounts](/manager-accounts). | MCP Tool | REST Endpoint | Method | | ---------------------------------- | --------------------------------- | ------ | | `erdo_list_managed_organizations` | `/v1/managed-organizations` | GET | | `erdo_create_managed_organization` | `/v1/managed-organizations` | POST | | `erdo_revoke_managed_organization` | `/v1/managed-organizations/:slug` | DELETE | Manager-key creation is REST/CLI/Platform only. It is intentionally not exposed as an MCP tool because the raw non-expiring credential must remain on a human-driven surface. Evals, Workstreams, and Experiments have their own REST endpoints — see [Evals](/evals), [Workstreams](/workstreams), and [Experiments](/experiments). ### Examples ```bash theme={null} # List datasets curl https://api.erdo.ai/v1/datasets?limit=5 \ -H "Authorization: Bearer YOUR_API_KEY" # Ask a data question curl -X POST https://api.erdo.ai/v1/ask \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"question": "What were total sales last quarter?"}' # Create a knowledge record curl -X POST https://api.erdo.ai/v1/knowledge \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Revenue analysis", "content": "Always compare to YoY when analyzing revenue", "description": "Revenue analysis best practice", "type": "skill"}' # Set a shared KV value (the canonical number everything references) curl -X PUT https://api.erdo.ai/v1/kv/pricing/items/monthly \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"value": "$29"}' # Read it back curl https://api.erdo.ai/v1/kv/pricing/items/monthly \ -H "Authorization: Bearer YOUR_API_KEY" # Write rows to a dataset curl -X POST https://api.erdo.ai/v1/datasets/my-org.metrics/rows \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"rows": [{"date": "2025-03-17", "revenue": 42300, "orders": 156}], "key_column": "date"}' # Delete rows from a dataset curl -X DELETE https://api.erdo.ai/v1/datasets/my-org.metrics/rows \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key_column": "date", "keys": ["2025-03-17"]}' # Create a heartbeat automation curl -X POST https://api.erdo.ai/v1/heartbeats \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Daily revenue check", "instructions": "Check revenue for anomalies", "interval_minutes": 60}' ``` ## Scoped Tokens & External Users When building your own app on top of Erdo, you'll want your end-users to interact with Erdo without giving them full access to your organization. **Scoped tokens** solve this — they restrict access to specific datasets and threads that you choose. All tools work with scoped tokens. Each tool automatically scopes results to the resources the token has access to. Create scoped tokens via the [TypeScript SDK](/ts-sdk/client) using `createToken()`: ```typescript theme={null} const token = await erdo.createToken({ datasetIds: ['dataset-uuid-1', 'dataset-uuid-2'], threadIds: ['thread-uuid-1'], }); // Pass this token to your end-user's MCP client const transport = new StreamableHTTPClientTransport( new URL('https://api.erdo.ai/mcp'), { requestInit: { headers: { 'Authorization': `Bearer ${token}` }, }, }, ); ``` ### How scoping works | Tool category | Scoped token behavior | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | **Data tools** (list, search, query, render, write, delete) | Only datasets included in the token scope. Write/delete requires edit permission. | | **Thread tools** (list, read, create, send) | Only threads in the token scope + threads they create. New threads are private to the user. | | **Knowledge tools** (create, search, list, delete) | Users create personal Knowledge records (not org-wide). Search/list returns their own records + public ones. Delete only works on their own records. | | **KV tools** (list, get, set, create) | Only KV stores the token holds access to. `set`/`create` require an EDIT grant. | | **Artifact tools** (list, get) | Only artifacts from their organization | | **Automation tools** (create, run, list) | Users create personal heartbeats (not visible to org). List/run only shows their own heartbeats. | Scoped tokens are designed for your customers' end-users. For your own team members, use organization API keys which have full access to all tools and org-wide visibility. ## Building Apps with Erdo MCP Beyond AI assistants, you can integrate Erdo's MCP server into your own applications: * **[Vercel AI SDK](/ts-sdk/vercel-ai-sdk)** — Connect any LLM to Erdo tools with rich chart and table rendering in React * **REST API** (above) — Direct HTTP integration without MCP * **Any MCP client** — Use the `Custom App` tab above to connect from TypeScript, Python, Go, or any language with an MCP client library # SMS Messaging Source: https://docs.erdo.ai/messaging Have an Erdo agent send outbound SMS — qualify a lead, confirm a detail, follow up — consent-gated and opt-out-aware, no setup required. # SMS Messaging An Erdo agent can send outbound **SMS** on your behalf through Erdo's managed number — the same Erdo-managed model as [Voice Calls](/voice). No account or carrier setup to connect; just ask the agent to text someone. > "Text +1 415 555 0142 to confirm their demo on Thursday at 2pm." Like voice calls, every send is **approval-gated** (you confirm it in chat first) and requires a **consent basis** — a concrete reason the message is permitted (explicit opt-in, an existing business relationship, an internal test). ## Opt-out is enforced Recipients who reply **STOP** are added to your organization's suppression list, and any further send to that number is **blocked before it leaves** — the agent is told the recipient opted out and no credit is charged. (STOP is also enforced at the carrier level, so opt-out is honored even on the very first reply.) Suppression is per-organization. ## Sending is rate-limited Erdo caps how many messages can go out so a loop or a mistake can't spam anyone: there's a **per-recipient** limit (you can't blast one number) and a **per-organization** limit over a rolling window. A send that would exceed either is **blocked before it leaves** and not charged — the agent is told to back off. A logical send can also carry an **idempotency key** so a retry never sends the same message twice. These limits, opt-out, and consent apply across every channel. ## What the agent does The `send_sms` capability: * Validates the number, body, and consent basis. * Checks the opt-out list and your credit balance first. * Sends from Erdo's number and records the message. * Charges a small flat credit cost per message, only after the message is accepted (a blocked or opted-out send is never charged). ## Lead outreach SMS pairs with [Voice](/voice) for lead outreach loops: text a list, capture replies, and let Erdo qualify and follow up. Combined with datasets and workstreams, a whole list can be worked end-to-end. Outbound SMS uses Erdo's shared number. Replies and richer channels (WhatsApp, RCS) are on the roadmap. # Offline conversions Source: https://docs.erdo.ai/offline-conversions Report leads back to Google Ads from the rows that captured a gclid, so conversion-based bidding learns which clicks became customers — and understand what Google's acknowledgement does and does not promise. # Offline conversions A landing page that carries no conversion tag still produces leads, and the bidding strategy paying for its clicks learns nothing from any of them. It keeps buying whatever it was buying, because as far as Google can tell nothing has happened since the click. Offline conversion upload closes that loop: the lead rows that captured a `gclid` are reported back to Google Ads from your data, so `MAXIMIZE_CONVERSIONS` and `TARGET_CPA` find out which clicks became customers and start bidding towards more of them. Ask an agent for it in the words you'd use anyway — "report last week's leads to Google Ads as conversions" — and it reads the lead rows, matches them to the conversion action, and files an [approval](/approvals) before anything reaches your account. Every upload is a write to a live ad account, so it is gated the same way a budget change is. ## Reconnect Google Ads once Google has moved offline conversion upload onto a separate API with a permission of its own, and a connection made before that change does not carry it. **If your Google Ads connection predates it, the first upload will stop and ask you to reconnect** — it will not try the call and fail halfway. Reconnecting takes one pass through Google's consent screen from **Settings → Integrations**, and Google will ask for the additional permission while you're there. Nothing else about the connection changes: your account and campaign selections stay as they are, your synced data keeps flowing, and every other Google Ads capability keeps working before, during, and after. Only the conversion upload waits on it. ## What "submitted" means Google acknowledges an upload immediately and processes it later, so the result you get back is honest about which one it is: > Submitted 412 click conversions to Google (processing asynchronously) That is an acknowledgement that Google **accepted the batch**, not a report that 412 conversions were recorded. The real processing finishes some time in the next 30 minutes to 24 hours, and Google's acknowledgement carries no per-row outcome at all — so Erdo reports the number submitted and the request id, and never a number of conversions "recorded", because that number does not exist yet. To find out what actually happened, ask the agent to check the upload's status using the request id from the result. It answers with one of: | Status | What it means | | ----------------- | ------------------------------------------------------------------------------------------------- | | `PROCESSING` | Google has the batch and is working through it. Expected for the first half hour — not a problem. | | `SUCCESS` | Every conversion in the batch was processed. | | `PARTIAL_SUCCESS` | Processed, with some records rejected. | | `FAILED` | Google rejected the batch's records. | Where records were rejected, Google reports **how many** for each reason — twelve duplicates, say — but not which rows they were. Erdo reports what Google reports, so nobody acts on a row-level detail that was never sent. ## Rejections are all-or-nothing If anything in an upload fails Google's validation, the **whole batch is rejected** and none of it is processed. There is no partial acceptance to sift through: correct what the error names and resubmit the batch. Erdo's result says so explicitly, so an agent retrying on your behalf resubmits everything rather than a subset that was never the problem. ## Only the click id is sent A conversion is matched to a click by its `gclid` and nothing else. Erdo does not send customer email addresses, phone numbers, or any other personal identifier to Google — not hashed, not encrypted — even though Google's API would accept them. If a lead row you're uploading from carries contact details, those fields are ignored rather than forwarded. ## Retracting a conversion you already reported Some reported leads turn out not to be leads — spam, a duplicate, a test submission. Left alone, the optimizer keeps bidding towards whatever produced them and your reported cost per lead stays wrong in the flattering direction. Ask the agent to retract them, and it reports the correction to Google against the same conversion action. Restating a conversion's value works the same way, for when what a lead was actually worth becomes known later. Retraction is unaffected by the reconnect above — it travels a different route and works on connections old and new alike. ## Related * [Ad accounts](/ad-accounts) — finding the account id an upload reports into * [Approvals](/approvals) — the gate every ad-account write passes through * [Dataset row actions](/dataset-row-actions) — the standing way to report new leads as they arrive, rather than uploading by hand each time * [Integrations](/integrations) — connecting and reconnecting Google Ads # Set up your workspace Source: https://docs.erdo.ai/onboarding Choose an outcome, add useful context, and start with either a conversation or a template. Erdo's first-run setup asks what you want to accomplish. You do not need to choose or configure agents: Erdo coordinates its internal capabilities for the work you ask it to do. ## Add the context you have Erdo can look up your business from its domain and suggest useful data connections for your goals. Connections are optional during setup. Skip them if you want to explore first, then add data later from **Data & connections**. Enterprise workspaces can also invite colleagues during setup. Personal workspaces can invite people later from team settings. ## Choose how to begin The final step offers two starting points: * **Start a conversation** for questions, research, analysis, and one-off pages or other outputs. * **Browse templates** for a guided flow with a defined, repeatable result. You can move between conversations and templates at any time. Choosing one does not lock the workspace into that mode. # Page analytics Source: https://docs.erdo.ai/page-analytics Query how your published pages perform with real visitors — views, conversions, and campaign attribution — with a read-only HogQL query over the org's own analytics events, from chat, MCP, REST, or the CLI # Page analytics When Erdo publishes a page for you, real visits to it are recorded into your organization's own analytics project. **Page analytics** is the read side of that: a single query surface that answers quantitative questions about how your published pages perform — how many people viewed a page, which variant converts, where the traffic came from. It is the same data the growth engine measures a variant against, exposed so your own products and dashboards can read it too. You ask the question as a query, not by picking from a fixed menu of reports. That is deliberate: the events carry enough structure that a new question — views this week, conversions by campaign, a daily series — is a new query, never a new endpoint to wait for. The query language is **HogQL**, PostHog's SQL dialect (ClickHouse under the hood), and it runs read-only against your organization's own project — the project is resolved server-side from your org, so a query can only ever see your own traffic regardless of what it selects. ## What every event carries The tracking snippet Erdo injects into a published page stamps each event with the properties you need to attribute it. The main table is **`events`** — one row per event, with `event` (the event name), `timestamp`, `distinct_id` (the visitor), and JSON properties you read as `properties.`: | Property | What it identifies | | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | | `properties.artifact_id` | The published page the event fired on | | `properties.variant` | The experiment variant, when the page is running an A/B test | | `properties.page_url` | The page URL | | `properties.utm_source`, `properties.utm_campaign`, `properties.utm_medium`, … | The campaign the visitor arrived from | PostHog auto-captures a `$pageview` event on every view, so `$pageview` is your denominator when you compute a rate. Pages can also emit named conversion events (a form start, a lead, a video play); if you're not sure what event names exist, list them first with a `SELECT event, count() FROM events GROUP BY event`. ## enabled: false means analytics is off, not empty Every result comes back as `{ enabled, columns, types, rows, truncated }`. The one field to read first is **`enabled`**. When it is `false`, page analytics is switched *off* for your organization — it does **not** mean zero traffic. Treat it as a prompt to turn analytics on (publishing a page, or the first view of one, also turns it on), not as an empty dashboard. When `enabled` is `true`, `columns` names the columns, `rows` holds the result positionally per column, and `truncated` is `true` when the result hit the row cap — aggregate further or add a tighter filter if you see it. ## What these numbers can't tell you These events record what visitors *did*. They do not record whether anything was tagging those visitors while they did it, and the two come apart in a way that matters: a month of well-engaged sessions looks identical whether or not a Meta pixel was cookieing the people in it, but only in the first case is there a retargeting audience to campaign against. Before you act on a number here — especially an audience size — read [page tracking](/page-tracking) to see which destinations are actually installed. ## Example queries **Views per page over the last week** — the everyday "how much traffic did each page get" read, grouped by the page's `artifact_id`: ```sql theme={null} SELECT properties.artifact_id, count() AS views FROM events WHERE event = '$pageview' AND timestamp > now() - INTERVAL 7 DAY GROUP BY 1 ORDER BY views DESC ``` **Views by campaign** — attribute traffic to the campaign that brought it, so you can see which `utm_campaign` is actually landing visitors: ```sql theme={null} SELECT properties.utm_campaign, count() AS views FROM events WHERE event = '$pageview' AND timestamp > now() - INTERVAL 30 DAY GROUP BY 1 ORDER BY views DESC ``` **Daily \$pageview series** — traffic over time, one row per day, to spot a launch spike or a slow week: ```sql theme={null} SELECT toDate(timestamp) AS day, count() AS views FROM events WHERE event = '$pageview' AND timestamp > now() - INTERVAL 30 DAY GROUP BY day ORDER BY day ``` Always bound a query by `timestamp` and add a `LIMIT` — the events table grows with your traffic, and an unbounded scan is slower and more likely to be truncated. If a query is rejected, the error carries PostHog's own diagnosis (an unknown column, a syntax slip), so you can fix the query and retry in place. ## In chat The simplest way in is to ask — the agent writes the HogQL for you: > "How many views did each of my published pages get this week, and which campaign drove > the most traffic?" The agent runs the query, reads the result, and summarizes it. If analytics isn't on yet, it will tell you and offer to enable it. ## CLI ```bash theme={null} # Views per page over the last 7 days, printed as a table erdo analytics query "SELECT properties.artifact_id, count() AS views FROM events WHERE event = '\$pageview' AND timestamp > now() - INTERVAL 7 DAY GROUP BY 1 ORDER BY views DESC" # Get the raw JSON result instead (columns, types, rows, truncated) erdo analytics query "SELECT count() FROM events WHERE event = '\$pageview'" --json ``` By default the CLI prints an aligned table of the result. When analytics is off for the org, it says so rather than printing an empty table. ## MCP The `erdo_page_analytics_query` tool takes a single `query` argument (the HogQL) and returns the `{ enabled, columns, types, rows, truncated }` result. Its description carries the events schema above, so an agent can write a correct query without first reverse-engineering the properties. ## REST | MCP tool | REST endpoint | Method | | --------------------------- | -------------------------- | ------ | | `erdo_page_analytics_query` | `/v1/page-analytics/query` | POST | ```bash theme={null} curl -X POST https://api.erdo.ai/v1/page-analytics/query \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "SELECT properties.artifact_id, count() AS views FROM events WHERE event = '"'"'$pageview'"'"' AND timestamp > now() - INTERVAL 7 DAY GROUP BY 1 ORDER BY views DESC" }' ``` The response mirrors the tool exactly: ```json theme={null} { "enabled": true, "columns": ["artifact_id", "views"], "types": ["Nullable(String)", "UInt64"], "rows": [ ["a1b2c3d4-0000-0000-0000-000000000000", 412], ["e5f6a7b8-0000-0000-0000-000000000000", 190] ], "truncated": false } ``` Querying needs **member** access to the organization; the query only ever reads that organization's own analytics project. # Page reviews — the critic on any URL Source: https://docs.erdo.ai/page-reviews Review any landing page URL with Erdo's real conversion critic: typed defects with severity, the principle each one violates, the evidence in the captures, and the fix — plus what the page does well. # Page reviews — the critic on any URL Erdo builds landing pages against a body of conversion-rate knowledge, and grades its own builds with a dedicated critic — a fresh-context discriminator that reads the *rendered* page and reports the concrete ways it loses visitors. A page review points that same critic at any public URL. You hand it one or more landing pages — your own, a competitor's, an agency client's — and it returns typed findings: what each page does well, and where it leaks conversions, with the reasoning and the pixel evidence behind every claim. This is the same critic that grades Erdo's own pages, not a separate prompt that happens to look similar. It is grounded in the one shared conversion-rate corpus the page *builder* also builds against, so when that knowledge improves, the reviews improve with it — there is no second critic to keep in sync. ## What you get back A review returns one entry per URL, and each entry is structured, not prose: * **Context** — the critic's committed one-sentence reading of what the page is selling, to whom, and at what stage, inferred from the captures alone. Every finding is judged relative to this reading, because the right *form* of each principle is category-relative: a pre-construction residence cannot have resident reviews, and its third-party proof is a named developer track record, an independent certification mark, or press — not star ratings. Surfacing the reading lets you challenge the premise, not just the findings. * **Strengths** — what the page genuinely does well against the conversion principles (a clear outcome-headline, proof above the fold in the strongest form the category can truthfully possess, one strong repeated call-to-action). * **Defects** — each a claim you could defend to a skeptical client: * **issue** — what is wrong, naming the specific element. * **basis** — how strongly the violated principle is known, as a typed field: `measured_prior`, `replicated_finding`, or `craft_prior`. A *measured prior* cites a real experiment corpus with a number (headline priors are mined from 29,423 statistically confident pairwise outcomes across 11,915 real Upworthy A/B tests), a *replicated finding* has been observed repeatedly across published CRO practice, and a *craft prior* is stated as exactly that. The critic never invents a study or statistic: a justification is only ever as strong as it truthfully is. * **because** — the reasoning: the specific conversion principle the element violates and why it costs *this* page conversions, carrying the measured prior's number when there is one. * **evidence** — the one observation the claim hinges on: the element or quoted copy and **which viewport** it was seen at. Deliberately minimal — the captures accompany the report, so evidence exists to let you locate and falsify the observation, not to narrate the page. * **fix** — the minimal change the evidence entails, left empty when the evidence dictates no obvious one (the critic is a discriminator, not a designer). * **viewports** — the capture labels (`desktop-fold`, `desktop-full`, `mobile-fold`, `mobile-full`) where the defect is observable, declared by the critic. A UI uses them to place each finding next to the exact capture it cites, instead of guessing from the evidence text. * Each defect carries a **severity** — `high` (broken or the conversion path materially damaged), `medium` (noticeably weakens conversion), or `low` (minor polish). * **Captures** — the screenshots the critic actually judged the page from, one per viewport (`desktop-fold`, `desktop-full`, `mobile-fold`, `mobile-full`). Each carries a stable `bucket_key` and a signed `url` you can render directly. The signed URLs **expire after about an hour** and are minted fresh on every read, so treat them as short-lived — re-`GET` the review for new ones rather than storing them. The captures let a report reader see what the critic saw, and, paired with each defect's `viewports`, anchor the finding to the frame it came from. Because the findings are typed, you can render them, sort by severity, or feed them into your own scoring — you are not parsing a paragraph hoping the shape holds. ## The scores Every reviewed page carries a **conversion score** (0–100) with a four-way breakdown, and — when available — its real **Lighthouse** scores. The scores are arithmetic, not opinion. Each of the four principle families — **Clarity**, **Credibility**, **Action**, and **Friction** — starts at 100 and subtracts a fixed cost for each of its findings: 18 for a `high`, 8 for a `medium`, 3 for a `low`, floored at 5 (each defect names its family, and a family with no findings stays at 100). The page's headline score is the **mean of those four family scores** — the breakdown you see genuinely composes the number above it. That makes every score exactly explainable ("Credibility is 82 because of one high finding"), stable (identical findings always produce identical scores), and honest about movement: nothing changes unless the findings change. The review as a whole carries the rounded mean across its pages. The **`lighthouse`** block is the genuine article: Google Lighthouse category scores (`performance`, `accessibility`, `best_practices`, `seo`, each 0–100) measured through PageSpeed Insights on the mobile strategy. It appears when the measurement succeeds and is simply absent when it doesn't — a review never fabricates a Lighthouse number. ## Tracking a page over time `GET /v1/page-reviews` lists past reviews, newest first, as lean summaries — id, status, the reviewed URLs, and the scores, without the findings (those stay on the single-review read). Pass `url` to see every review that included one exact page, which is how you watch a page's score move as it changes: ```bash theme={null} # Every review that included this page, newest first curl "https://api.erdo.ai/v1/page-reviews?url=https://acme.com/landing&limit=50" \ -H "Authorization: Bearer $ERDO_TOKEN" ``` ```bash theme={null} # The same from the CLI erdo pages reviews --url https://acme.com/landing ``` ## How it sees the page The critic never guesses from source; it judges what a visitor actually sees. Each URL is captured at **four frames**: the fold and the full page, on desktop (1280×800) and on a phone (390×844). The **fold** shots — exactly what shows before scrolling — are where the most damaging, most-missed defects live: a headline clipped on mobile, a call-to-action pushed below the fold. The **full-page** shots walk the entire scroll height, so "there's nothing below the hero" is a finding grounded in the whole page, never an artifact of a capture that couldn't scroll. A defect that only appears at one width cites the viewport it was seen at, so you know whether it hits desktop, mobile, or both. Only public `http(s)` URLs are reviewed. A private address or a signed-in Erdo workspace URL is refused — the renderer is anonymous and would only capture a login screen; publish the page and pass its public URL instead. ## Declaring the goal and the audience The pixels tell the critic what the page *is*; they can't tell it what the campaign is *for* or *who* it is aimed at. So a review takes two optional declarations, each up to 500 characters: * **`goal`** — what the page is trying to achieve ("book qualified demos", "build launch awareness"). A page built for brand awareness and one built to book demos can look almost identical, yet the right findings differ — the awareness page is not defective for lacking a hard-conversion call-to-action, while the demo page lives or dies on form friction and CTA clarity. * **`audience`** — who the page targets, your ICP ("out-of-state condo investors buying pre-construction", "ops leads at 50–500-person logistics firms"). The critic then judges clarity, proof, and tone through *that* reader's eyes: what they must grasp in five seconds, which proof forms they weigh, what tone earns their trust. Without it the critic infers the audience from the page — and hedges when the page itself is ambiguous about who it's for. Declarations steer *how* the page is judged — never the evidence bar — and the critic still infers its own reading from the captures rather than taking your word for it. The most useful consequence: when the page's evident form **contradicts** a declaration — built as a hard funnel against an awareness goal, or speaking to a different reader than the audience you named — the critic reports that mismatch as a defect in its own right, at the severity it costs. Both declarations are echoed back on the review so a reader can see the frame the findings were judged against. ## Running a review A review is asynchronous: you submit URLs and get a **review id** back immediately, then read the findings once they land. A single unreachable URL never sinks the review — it reports its own reason while the other pages still return findings. Ask an agent in a thread: > "Review these three landing pages and tell me which one is weakest and why." Or run it directly: ```bash theme={null} # CLI — submit URLs and wait for the findings erdo pages review https://acme.com/lp-a https://acme.com/lp-b --wait # Submit and poll later by id erdo pages review https://acme.com/lp-a erdo pages review-result ``` ```bash theme={null} # REST — start a review (goal is optional) curl -X POST https://api.erdo.ai/v1/page-reviews \ -H "Authorization: Bearer $ERDO_TOKEN" \ -d '{"urls": ["https://acme.com/lp-a", "https://acme.com/lp-b"], "goal": "book qualified demos"}' # Read it back (status → ready, then the typed findings) curl https://api.erdo.ai/v1/page-reviews/ \ -H "Authorization: Bearer $ERDO_TOKEN" ``` The MCP tools are `erdo_review_pages` (start) and `erdo_get_page_review` (read). All three surfaces return the review id immediately; the status moves `pending → capturing → reviewing → ready` (or `failed` if not one page could be captured). ## Where it fits A page review judges *one page at a time* — its strengths and defects in isolation. When you want to know which of several variants is likely to *win*, run the [persona panel](/persona-panel): it ranks a field of variants by predicted conversion. The two compose naturally — the critic tells you what to fix on each page, the panel tells you which page to bet on — and both read the page the same way, from rendered captures rather than source. # Page tracking Source: https://docs.erdo.ai/page-tracking Read which analytics destinations your published pages send visitor data to — session analytics, GA4, the Meta pixel — so you know whether visitors are actually being tagged before you draw a conclusion from your traffic numbers. # Page tracking [Page analytics](/page-analytics) tells you what visitors did on your published pages. **Page tracking** tells you whether anything was tagging them while they did it. Those are different questions, and confusing them produces a specific, expensive mistake. Here is the mistake. You read your page events, count the visitors who scrolled deep and lingered, and find 376 well-engaged sessions over the last month — comfortably enough to build a retargeting audience from. So you plan the campaign. But a retargeting audience is not built from your page events; it is built by the **Meta pixel** dropping a cookie on each of those visitors as they arrive. If no pixel was firing on those pages, the audience does not exist at Meta and never did. The 376 is a true number, and every conclusion it invited was wrong. Nothing in the traffic data can contradict that, because the traffic data is the same either way. The only thing that can is a read of what is actually installed. That is this endpoint. ## What comes back One record for your whole organization: the destinations your published pages send to, plus the two governance settings that apply to all of them. ```json theme={null} { "destinations": [ { "kind": "session_analytics", "provider": "posthog", "enabled": true, "public_id": "phc_abc123...", "host": "https://us.i.posthog.com" }, { "kind": "google_analytics", "provider": "google", "enabled": true, "public_id": "G-XXXXXXXXXX", "host": "" } ], "mask_inputs": true, "consent": "implied" } ``` Read `kind` first — it is the Erdo capability, and the field to branch on: | Kind | What it does | | ------------------- | -------------------------------------------------------------------------------------------- | | `session_analytics` | Heatmaps and session replay, and the events behind [page analytics](/page-analytics) queries | | `google_analytics` | GA4 measurement | | `meta_pixel` | Cookies visitors so Meta retargeting audiences accumulate | The example above is the shape that causes the mistake: session analytics and GA4 are both on, so traffic is being measured in detail — and there is no `meta_pixel` entry at all, so none of those visitors is retargetable. **A kind missing from the list is not configured**; a kind present with `enabled: false` is configured and switched off. Keep those apart, because they are different problems with different fixes. These are the **vendor** destinations configured on your organization. Erdo also runs a first-party page-events beacon, which writes to your own page-event [dataset](/data); it is provisioned per page when the page first renders rather than stored on the organization, so it never appears in this list. An empty `destinations` means no vendor is tracking your pages; it does not mean nothing is recording. (`session_analytics` is the destination behind [page analytics](/page-analytics) HogQL queries — that is a vendor destination, and it does appear here.) `public_id` is the client-side identifier already embedded in your published page's JavaScript — a PostHog `phc_…` project key, a GA4 `G-…` measurement id, a numeric Meta pixel id. It is there so you can check the *identity* of what fires, not just its presence: a pixel can be enabled and still belong to an ad account your campaigns don't run in, in which case the audience it builds is one nothing can target. Match the `public_id` against the pixel your ad account holds and that ambiguity disappears. `host` is the ingest host events are sent to; empty means the provider's default. It travels with `public_id` because the same key means different projects on different provider regions. ## Masking and consent Two settings govern every destination at once. **`mask_inputs`** is whether form-input values are masked in session replay. Lead forms carry PII, so if you are about to surface replays — or reason about what a recording contains — this is the field that tells you whether the values a visitor typed were captured. **`consent`** is `implied` or `required`. Under `required`, recording waits on a consent banner, so thin event volume can be the consent gate rather than thin traffic. Read this before concluding a page is getting no visitors. ## Reading it ```bash CLI theme={null} erdo analytics tracking ``` ```bash REST theme={null} curl https://api.erdo.ai/v1/page-tracking \ -H "Authorization: Bearer YOUR_API_KEY" ``` The CLI prints every kind, including the ones you don't have, precisely because the useful answer is usually an absence — and it says so explicitly when no Meta pixel is firing: ``` kind status public id provider what it does ----------------- -------------- ------------ -------- ---------------------------------------------------------------------- session_analytics on phc_abc123 posthog heatmaps, session replay, and the events behind `erdo analytics query` google_analytics on G-XXXXXXXXXX google GA4 measurement meta_pixel not configured cookies visitors so Meta retargeting audiences can be built session replay input masking: on consent: implied No Meta pixel is firing on these pages, so no retargeting audience is accumulating — page traffic alone does not mean those visitors can be served ads. Erdo's own page-events beacon is provisioned per page at render time and is not listed above — it records independently of every vendor here. ``` In chat or over MCP, the `erdo_get_page_tracking` tool returns the same record, so an agent asked "can we retarget the people who visited this page?" can check before answering. ## Read-only, and why There is no write endpoint, by design. Destinations are provisioned by Erdo into the account that has to be able to *use* them — your own connected ad account for a Meta pixel, Erdo's measurement infrastructure for session analytics and GA4. A pixel created anywhere else is invisible to the campaigns meant to target it, so letting a caller name an arbitrary account would mostly produce configurations that silently do nothing. Masking and consent are yours to change, in your organization's settings. Only public, client-side identifiers are returned — the same values a visitor can read in your page's source. Nothing that authenticates against a provider, and not the internal numeric project id Erdo uses to read your analytics data back, ever crosses this API. ## Permissions Reading page tracking needs **member** access to the organization, and always reports that organization's own configuration — the organization is resolved from your API key, never passed in. # Pages Source: https://docs.erdo.ai/pages Turn a result into a shareable, data-wired app — dashboards, reports, and tools Erdo builds for you. A **page** is a small app Erdo builds for you: a dashboard, a report, an interactive tool. It's wired to your data with live values, runs in a managed sandbox under your permissions, and can be shared with your team or published. ## Getting a page You don't drag and drop — you ask. In a [conversation](/concepts#conversations), describe what you want and Erdo builds it: > "Turn this into a dashboard showing signups by plan over time, with a filter by > region, that I can share with the team." Erdo generates the page, wires it to the right [data](/data), and shows it back to you. Ask for changes in the same conversation and it rebuilds. ## Pinning pages to a project When a conversation belongs to a project, every page it creates is added to that project's recent outputs automatically. This keeps the result discoverable without changing how you work in the conversation. Pin the pages that should define the project from the pin button in the page toolbar, or from **Recent pages** on the project home. Pinned pages appear at the top of the project home and in the project's sidebar. Unpinned outputs stay available in **Pages** and **Recent pages**, so generating a one-off output does not force it into a project. ## Live, not a snapshot Pages read your data at view time, so they stay current — open one tomorrow and it reflects tomorrow's numbers. They can also hold their own state and react in realtime when more than one person is looking. ## Self-healing A page is real code wired to data that changes over time, so either side can drift: the code can hit a case that only shows up later, or a dataset the page reads can be reshaped by a refresh weeks after the page was built. Erdo keeps pages working without you having to watch them. When Erdo builds or edits a page, it validates the code and its data queries before the page is saved. It re-verifies every page after each dataset refresh, so a change to your data can't quietly break a chart. And it monitors your published and shared pages for errors as real visitors view them. When something breaks at any of those points, Erdo automatically diagnoses the problem and prepares the fix — usually before you'd have noticed. A fix to a private draft is applied on its own; a fix to a published page is proposed for your approval first (or applied automatically if you've set an approval policy for page edits), so a live page never changes without your say-so. Every fix is a new revision, so nothing is lost. ## Sharing * **Private** — open to people in your workspace, under your existing permissions. * **Public link** — publish a read-only version anyone with the link can view. Identity and access always come from the viewer's own permissions, so a page can't expose data the viewer isn't allowed to see. ## Managing pages Pages have a full lifecycle you can drive from a conversation, the CLI, the REST API, or MCP: * **List** the pages in your workspace, newest first, and filter by title or a created-at window to find a specific set. * **Delete** a page. This is a soft delete: its public share link stops working immediately and it drops out of listings, but it is not gone. * **Restore** a deleted page to bring it back. It returns **private** — deleting revoked its public grant, so re-publish it if you want the share link live again. In a conversation you just ask ("list my pages", "delete the old pricing page"). From the terminal: ```bash theme={null} erdo pages list --query "pricing" erdo pages delete erdo pages restore ``` The same operations are on the REST API (`GET /v1/pages`, `DELETE /v1/pages/:id`, `POST /v1/pages/restore/:id`) and as the MCP tools `erdo_list_pages`, `erdo_delete_page`, and `erdo_restore_page`. Building pages programmatically (deploying via the API, MCP, or SDK, and the `window.erdo` client) is covered in the developer guide, [Build Apps](/apps/build-apps). ## Session replay & heatmaps Your published pages come with **session replay and heatmaps** automatically — see how real visitors actually behave: where they click, how far they scroll, where they drop off. There's nothing to turn on. The first time a public page is visited, Erdo provisions an isolated analytics project for your organization (so your data is never mixed with another customer's) and records real visitors from then on — this applies to pages you've already published, not just new ones. A few things are handled for you: * **Form inputs are masked by default** — names, emails and other typed values are hidden in replays, so you see behaviour without capturing personal data. * **Your own previews don't count** — pages opened in preview/screenshot mode (and staff previews) are never recorded, so your numbers reflect real visitors only. * **It's per-organization** — every published page in your workspace shares one project, and that project is yours alone. Just ask to review them: > "How are visitors using my landing page — where are they dropping off?" ### Querying your page analytics Beyond individual session replays, Erdo can **query your page-analytics events directly** — every event a published page fires carries the page, the experiment variant (when one is running), the URL, and campaign parameters, so questions about real visitor behaviour are answerable with a query rather than a fixed report. Conversion rates per variant, form starts by campaign, drop-off between pageview and lead submission: Erdo writes the measurement to fit the question. When you're running page variants against each other, ask: > "Which variant of my landing page converts better — show me form starts and lead > submits per variant for the last two weeks." Queries are read-only and always scoped to your organization's own analytics project. This is telemetry Erdo collects from your published pages, not data from a separate analytics-provider account you may have connected to Erdo. Ask explicitly about that connected provider when you want Erdo to inspect the provider account instead. ### First-party page events Your published pages also record their key conversion events **directly into your own Erdo workspace** — first-party, with no retention window and no vendor in the read path. Each published page posts a small, fixed set of named events to its own [event pipeline](/event-pipelines), which writes them to a shared **Page events** [dataset](/data) in your organization: | Event | Meaning | | -------------------------- | --------------------------------------------------------------------------------------------------------------- | | `pageview` | A visit — the denominator for every rate | | `scroll_50` / `scroll_100` | The visitor scrolled halfway / to the end | | `section_visible` | A named page section entered the viewport (section id in `params`) | | `cta_click` | A click on a call-to-action | | `form_start` | First focus on the lead form | | `generate_lead` | A completed lead submission — joins to the lead row itself | | `watch_video` | A visitor watched a video with sound — the first sound-on playback (ambient muted autoplay loops never fire it) | Every row carries the page (`artifact_id`), the **experiment variant the visitor was actually served**, a per-visit session key, the page URL, and campaign attribution (`utm_*`, `gclid`, `fbclid`) — so per-variant funnels are a plain SQL query over your own dataset, and they line up with your leads because both streams stamp identical attribution. Ask Erdo: > "From the Page events dataset, show pageview → form start → lead conversion per variant > for the last two weeks." This is a curated stream of discrete events, not a session recorder — session replay, heatmaps, and click autocapture stay with the session-analytics project above. It's on automatically for published pages (the first public view provisions the pipeline and dataset), preview and screenshot renders never emit, and events with names outside the fixed set are rejected at the pipeline. To turn it off for a page, disable that page's page-events pipeline. ### Google Analytics Your published pages are also wired up to **Google Analytics** automatically — Erdo provisions an isolated GA4 property for your organization and tracks pageviews and events there, alongside session replay. Same as session replay: nothing to set up, no measurement ID to paste, no Google account of your own required — it's handled for you and your data stays in your own property. # Paid-media campaign lifecycle Source: https://docs.erdo.ai/paid-media Pause, resume, or re-budget a provider ad campaign over /v1 — through the same approval gate that governs agent-proposed changes # Paid-media campaign lifecycle Erdo can read a connected ad account in full — campaigns, spend, status, keywords — and it can also **act** on a campaign: pause it, enable it, or change its daily budget. The lifecycle endpoints expose exactly those three actions over `/v1` (and the matching MCP tools), so an external product or script can drive a decision the evidence argues for instead of someone performing it by hand in the provider's console. Every lifecycle call goes through the **same approval gate that governs agent-proposed changes**. There is one gated path into a customer's ad account, not two: the `/v1` call files the very action an agent's tool would file — same action key, same input, same deduplication — and a caller with an API key is never more trusted than an agent. Supported providers: **`google_ads`**. Any other provider is rejected with an `unimplemented` error rather than silently ignored. ## The two outcomes What happens depends on whether your organization has granted a **standing always-approve policy** for the action (the same standing grant you create by choosing "always allow" when deciding an agent's approval): * **No standing policy — propose.** The call does **not** touch the provider. It files a pending [approval request](/approvals) and returns `status: "pending_approval"` with the `approval_request_id` — accepted, not performed (202 semantics, expressed in the body). The approval appears in the activity feed and on `GET /v1/approvals`, stamped with `subject_resource_type: "paid_media_campaign"` and the campaign's external id so you can list everything awaiting decision about one campaign. When a human approves it — feed, CLI, or `POST /v1/approvals/{id}/decide` — the exact stored action executes, under the approver's identity. A rejection is terminal and nothing reaches the provider. * **Standing policy — execute.** The call executes immediately through the same integration handler an agent-run approval would execute, and returns `status: "executed"` with the provider's result. Re-proposing the same change while one is pending folds onto the existing request instead of filing a duplicate — including a budget change with a different amount, which is still "the same decision to review" and updates the pending card rather than stacking a second one. ## Set campaign status ``` POST /v1/paid-media/campaigns/{externalID}/status ``` ```json theme={null} { "provider": "google_ads", "customer_id": "1234567890", "status": "paused", "reason": "CPL 2.1x target over the last 14 days" } ``` * `externalID` (path) — the provider's campaign id. * `provider` — `google_ads`. * `customer_id` — the provider account the campaign belongs to (campaign ids are only unique within an account). Required. * `status` — `paused` or `enabled`. Removal is deliberately not exposed: deleting a campaign is destructive, not lifecycle. * `reason` (optional) — shown on the approval card as the *why* beside the *what*, so the approver reads the evidence, not just the action. ## Set campaign daily budget ``` POST /v1/paid-media/campaigns/{externalID}/budget ``` ```json theme={null} { "provider": "google_ads", "customer_id": "1234567890", "daily_budget_micros": 50000000, "reason": "rank-constrained; excess budget buys no additional impressions" } ``` * `daily_budget_micros` — the new daily budget in micros of the account currency (`50000000` = \$50.00). Must be positive. ## Response ```json theme={null} { "status": "pending_approval", "approval_request_id": "0f9f6c1e-…", "action_display": "Set Google Ads campaign 'Brand — Miami' (23926631489) status to 'PAUSED'" } ``` or, when a standing policy covers the action: ```json theme={null} { "status": "executed", "action_display": "Set Google Ads campaign 'Brand — Miami' (23926631489) status to 'PAUSED'", "result": { "success": true, "campaign_id": "23926631489", "...": "…" } } ``` ## Following up on a pending action List what is awaiting decision about one campaign: ``` GET /v1/approvals?status=pending&subject_resource_type=paid_media_campaign&subject_resource_id=23926631489 ``` Decide it: ``` POST /v1/approvals/{approval_request_id}/decide { "decision": "approved" } ``` Approving with a broader scope (`always_org`, with parameter constraints) creates the standing policy that makes subsequent identical calls execute immediately — see [Approvals](/approvals) and [Autonomy](/autonomy). ## MCP tools The same capability is available as MCP tools: * `erdo_set_paid_media_campaign_status` — provider, customer id, campaign id, `paused`/`enabled`, optional reason. * `erdo_set_paid_media_campaign_budget` — provider, customer id, campaign id, daily budget in micros, optional reason. # How we validate synthetic panels Source: https://docs.erdo.ai/panel-validation The Upworthy benchmark, isotonic calibration, and the falsification gate — how Erdo checks that its synthetic-panel predictions track reality, and why the gate that says when it doesn't know is harder to copy than any single accuracy number. # How we validate synthetic panels A synthetic panel is only worth running if its predictions track what real visitors actually do. Anyone can build a panel that returns a ranking; the question a buyer should ask is whether that ranking has ever been checked against reality, and what happens when it is wrong. This page is Erdo's answer: the public benchmark we score the panel against, the correction that turns a model's raw guess into a calibrated effect, and the falsification test that refuses a result it cannot stand behind. ## Methodology summary Erdo validates its synthetic panels against the Upworthy Research Archive — 32,487 real headline A/B tests, each with the true click-through rate that reality measured — the same public benchmark the leading academic studies use. The panel predicts which variant will win, several times over, and its predictions are corrected against real outcomes with a calibration curve fitted so that no test ever helps score itself. A falsification test then checks whether the corrected predictions are statistically consistent with the truth: when they are not, the benchmark reports that the configuration failed rather than publishing a number it cannot defend. The result is not just an accuracy figure but a system that states, on evidence, when it knows and when it doesn't — which is the property that matters when a prediction is about to steer real ad spend. ## The benchmark: a public archive with real outcomes You cannot check a prediction against reality without reality to check it against. The Upworthy Research Archive is the rare public dataset that provides it: for tens of thousands of real headline A/B tests run on a high-traffic news site, it records every variant that was shown and the click-through rate each one actually earned. That makes it a ground truth a synthetic panel can be graded on directly — predict the winner, then compare against the outcome the archive already measured. Two points of hygiene travel with the data. The archive is published under the Creative Commons Attribution 4.0 licence (Upworthy Research Archive, J. Nathan Matias, Kevin Munger, et al., [osf.io/jd64p](https://osf.io/jd64p/)), and Erdo carries that attribution wherever the benchmark is reported. And a June 2024 erratum identified randomisation problems in a window of the earliest tests; every test created between 25 June 2013 and 10 January 2014 is excluded from the benchmark, so the number is measured only on tests whose outcomes can be trusted. ## Prediction: ask several times, then average A single model prediction is noisy — ask the same question twice and the answers wobble. So the panel does not read a variant once. It predicts the outcome several times over (five independent draws is the default), and averages them. Averaging several draws is a well-established way to recover a stabler estimate than any single read, and it is cheap: the whole exercise costs a few dollars and a couple of minutes per variant, against the hundreds of dollars of paid traffic it takes to read one variant for real. ## Calibration: correct the scale against reality Models are far better at direction than at magnitude — good at saying which variant is stronger, unreliable at saying by how much, and they tend to exaggerate the size of an effect. Using their raw numbers to steer spend would import that exaggeration wholesale. So Erdo never uses the raw prediction directly. It fits a calibration curve that maps predicted effects onto the real effect scale, learned from the pairs of prediction and measured outcome the benchmark provides. The curve is monotone — a higher predicted effect always maps to a higher calibrated one, because inverting that order would be fitting noise, not signal — and it is fitted with cross-validation, so the correction applied to any one test is learned only from other tests and never from the test's own outcome. That discipline is what keeps the reported accuracy honest: a model that got to see the answer before grading itself would look better than it is. ## The falsification gate: the system says when it doesn't know Calibration can make predictions look accurate; it cannot, by itself, prove they are trustworthy. The falsification test is the check that can. After calibration, it asks a single statistical question: are the corrected predictions consistent with being unbiased estimates of the real effects? If the corrected predictions systematically miss — too confident, biased in one direction, drifting away from the measured truth — the test detects it and the configuration fails the gate. A failed gate is not a number to be spun; it is the benchmark refusing to publish a result it cannot defend. This is the honest differentiator, and it is why Erdo publishes the method rather than only a figure. A flat accuracy claim tells you how a system did on someone's chosen slice of data; it tells you nothing about when to distrust it. A gate that can fail tells you the system will decline to guess when its predictions stop tracking reality — and that is a harder thing to copy than any single accuracy number, because it requires the reality-paired ledger to run against in the first place. ### The gate demonstrated in both directions The gate is only meaningful if it can actually fail, so the calibration run tested it on two configurations of the same pipeline. A deliberately cheap configuration — a small model with minimal effort — scored around 0.60 directional and **failed** the falsification test (z = 3.69): its corrected predictions were provably inconsistent with the truth, and the gate refused them. A production-tier configuration on the same tests **passed** (p = 0.64), and recovered the effect scale from 0.27 to 0.68 of the true magnitude. One pipeline, two configurations, correctly rejected one and accepted the other. That discrimination — accepting the good configuration and refusing the cheap one on the same data — is the property the whole system leans on. ## Current benchmark result The full Upworthy holdout run that produces Erdo's headline directional hit-rate — the single accuracy figure an agency report would quote — is a deliberate, staged run over the metered API path that has not yet been executed; this page will state that number, with its confidence interval and the gate verdict, once it has. What is already established is the gate itself, demonstrated above on real archive data in both directions: the calibration correctly failed the cheap configuration and passed the production-tier one. A separate, smaller diagnostic run has also been recorded, through a different invocation path: on 2026-07-17, a run of the same estimator through the harness's local `claude-cli` subscription runner (rather than the metered API) scored 0.65 directional hit-rate on its most confident pairs and **failed** the falsification gate — a known reproducibility gap between that runner and the API path, still under investigation, recorded at `tools/upworthy-harness/results/sonnet-cli-196.json`. Until the full metered-API run lands, that is the honest state of the benchmark — the method is proven, the accuracy figure is pending, and Erdo would rather say so than publish a number it has not yet measured. When the run completes, the hit-rate drops in here as the maintained, provenance-stamped number, whatever the gate verdict turns out to be. The number is a property of the engine, produced and published platform-side, so every product that runs on the panel inherits the same validated method. White-label and agency reports may quote the [methodology summary](#methodology-summary) above and, once published, the benchmark figure — a client does not need to understand isotonic regression to rely on a panel whose predictions are checked against reality and whose gate declines to guess when they stop tracking it. # Persona panel — the wind tunnel Source: https://docs.erdo.ai/persona-panel Test page variants against your brand's customer cohorts before spending a cent of paid traffic: synthetic personas predict each variant's micro-conversions, rank the field, and flag the losers. # Persona panel — the wind tunnel Paid traffic is the most expensive way to find out a landing page doesn't work. A statistically useful read on one variant costs hundreds of dollars of clicks; a field of five variants costs five times that, mostly spent on the losers. The persona panel is the wind tunnel you run first: a jury of synthetic visitors — built from your own brand research — reads each variant the way a real visitor would and predicts how it will perform, for cents rather than hundreds of dollars, in minutes rather than weeks. The panel **prunes, it never concludes**. Even the best published systems for predicting A/B winners are right roughly three times out of four, so a panel ranking is a prior for which variants deserve real traffic — the [experiment](/experiments) with live visitors still makes the call. What makes the panel more than a guess is that every prediction it writes is later scored against what reality measured on the same variant and metric, so its track record is a number you can check, not a promise. ## Personas come from your brand brief A persona is not a stock demographic. When the lead-engine flow researches a customer and writes its Brand Brief, each marketing angle is grounded in a real buyer cohort — who they are, what outcome they want, what makes them skeptical, what language they read in. Each active cohort is materialised as a **persona skill**: a [Knowledge](/knowledge) skill whose body is that identity, plus declared sampling ranges (patience, scroll speed, device mix) so no two simulated visits are identically robotic. Personas are addressed by slug — `persona-{customer}-{cohort}`, e.g. `persona-acme-robotics-roi-led` — and that slug is how every prediction is attributed in the experiment ledger. You can refine a persona like any other skill ("make the ROI-led persona more price-sensitive"), or create one directly by asking an agent, and the next panel run uses it. ## Personas learn from your real traffic A brand-brief cohort is a good starting point, but it encodes what you *believe* about your audience, not what your audience *does*. Once your published pages have accumulated real traffic, Erdo grounds your personas in it. A weekly grounding pass clusters the last 28 days of your first-party page-events sessions into behavioral archetypes — by where they arrived from, how far they engaged, and whether they converted — and rewrites each matching persona in place: the authored identity (its voice, its objections) stays, but a **measured section** now carries the cohort's real intent rates, and the persona's sampling ranges come from the cluster's statistics instead of an authored guess. The slug never changes, so the persona's calibration history stays continuous across the update. Grounding is conservative by design, because a persona built on noise poisons every prediction that uses it: * **It never runs on thin data.** No grounding below 1,000 sessions in the window, and no cohort becomes a persona below 150 sessions and 10 conversions. Below those gates the pass is a silent no-op — a data-starved org keeps its authored personas untouched. * **It updates, it doesn't multiply.** Each cluster refreshes the persona closest to it in channel and intent shape. A cluster that matches nothing — a genuinely new segment in your traffic — files a single item in your [Activity feed](/attention) proposing a new persona, rather than quietly minting one. Because every prediction records which version of a persona made it, the [judge calibration readout](/experiments) splits each persona's accuracy by grounding source — authored vs. measured — so you can watch grounded personas out-predict their authored selves as your traffic teaches them. ## What a panel run does Given an experiment whose variants are pages — [Erdo pages](/pages) or external page URLs (see below) — the panel: 1. **Renders each variant once, mobile-first** — the full page, the lead-form modal open, and the modal with the keyboard up: the three views a phone visitor actually experiences. Every persona judges the same renders, so variants are compared like-for-like. 2. **Runs each persona over each variant, several times.** Each persona×variant cell is sampled K times (default 5) and the prediction is the average — single reads of a synthetic visitor are noisy in a way repeated draws largely cancel. 3. **Predicts the standard page metrics.** Each cell forecasts, as per-visit probabilities, the same micro-conversion vocabulary your live pages emit — scrolling halfway and to the end, clicking the call-to-action, starting the form, submitting a lead, playing a video — plus an attention walk over the page's sections and the point where the persona would give up and why. 4. **Writes it all into the experiment ledger.** Every forecast is an ordinary experiment observation attributed to its persona, and one aggregate records the ranking and the kill list — the variants predicted at less than half the leader's rate. Because predictions and real measurements share one vocabulary and one ledger, calibration is automatic: once live traffic measures the same variants, the [judge calibration readout](/experiments) shows how often each persona ordered variants the way reality did. ## Panelling pages that aren't Erdo pages A variant's page doesn't have to be built in Erdo. When you set a variant's treatment to an external URL instead of an Erdo page, the panel renders that public page directly and predicts on its screenshots exactly as it does for an Erdo page — so an agency can paste a client's live landing pages, run the panel over them, and get a ranking without rebuilding anything first. Set it with `url=` on the CLI's `--variant` flag, or `treatment_url` when you create the experiment over MCP/REST; it is mutually exclusive with an Erdo-page treatment (a variant is one or the other). External pages get fewer renders than Erdo pages: only the mobile full-page shot, not the lead-modal-open and keyboard-up views. Those extra shots depend on knowing the page's own lead call-to-action, which an arbitrary page doesn't expose, so the panel captures what it safely can and records exactly which views each persona saw alongside every forecast. Only public `http(s)` URLs are visited — a private address or a signed-in Erdo workspace URL is refused, because the renderer is anonymous and would only capture a login screen. ## Running a panel The panel is part of how Erdo operates experiments, not just an endpoint: the agent that runs your workstreams and experiments carries the panel, the diagnosis sessions, and the decision-policy readout as its own tools, and its operating rules tell it to run the panel on any new variant set *before* paid traffic is spent, to treat predicted magnitudes as pruning evidence until the calibration record supports them, and to use Stage B only for diagnosis. So when experiments run autonomously, the wind tunnel is already in the loop — you don't have to remember to ask. Ask an agent in a thread: > "Run the persona panel on the hero-copy experiment and kill the losers." Or run it directly: ```bash theme={null} # CLI — starts the panel and waits for the ranking erdo experiment panel hero-copy-test --wait # Restrict to specific personas or variants, or change the draw count erdo experiment panel hero-copy-test --persona persona-acme-robotics-roi-led --draws 8 ``` ```bash theme={null} # REST curl -X POST https://api.erdo.ai/v1/experiments/hero-copy-test/panel \ -H "Authorization: Bearer $ERDO_TOKEN" \ -d '{"draws": 5}' ``` The MCP tool is `erdo_run_persona_panel`. All three surfaces return a `panel_run_id` immediately — the panel runs in the background, and its results are ordinary observations: ```bash theme={null} # The ranking + kill list (one decision_check row carries them) erdo experiment observations hero-copy-test --type decision_check # Every per-persona prediction erdo experiment observations hero-copy-test --type prediction ``` ## Reading the output honestly * **Compare within a run, not across runs.** A panel run pins its screenshots, prompt, and model, so its numbers are comparable to each other; a different run may sit higher or lower overall. * **The ordering is the signal.** Absolute probabilities from synthetic visitors are uncalibrated until enough real measurements have paired against them; which variant beats which is the part that transfers. * **Reasoning is explanation, not evidence.** Each prediction carries the persona's one-line rationale and each cell a bail point — useful for diagnosing *why* a variant loses, never a substitute for the numbers. ## Stage B — diagnostic sessions on the survivors The panel tells you *which* variants win and lose; it can't always tell you *why* the losers lose. Stage B answers that: for the top surviving variants, each persona actually browses the page in a real mobile browser session and narrates its walk — what it read, where its attention broke, and where it gave up and why. Sessions are deliberately diagnostic only. A handful of browsing agents is far too small a sample to estimate conversion, so Stage B never produces a number, only the story behind the panel's numbers — the personas are even barred from submitting forms, because the surviving pages are live and a submission would write a fake lead. Run it after the panel, on the panel's ranking (the default), or hand it your own: ```bash theme={null} # Uses the latest panel ranking, sessions on the top 3 survivors erdo experiment diagnose hero-copy-test # Your own survival order, more survivors, specific personas erdo experiment diagnose hero-copy-test --top-k 4 --survivor b a d --persona persona-acme-robotics-roi-led ``` ```bash theme={null} # REST curl -X POST https://api.erdo.ai/v1/experiments/hero-copy-test/diagnose \ -H "Authorization: Bearer $ERDO_TOKEN" \ -d '{"top_k": 3}' ``` The MCP tool is `erdo_diagnose_experiment_personas`. Sessions run in the background; each persona×survivor session lands as an `action_taken` observation carrying the bail narrative and the typed browsing trace: ```bash theme={null} erdo experiment observations hero-copy-test --type action_taken ``` # Projects Source: https://docs.erdo.ai/projects Separate related work inside one organization and keep the Erdo app focused on the active project. # Projects An **organization** is the tenant and security boundary in Erdo. A **project** is a work context inside that organization: use projects to separate customers, developments, campaigns, or other initiatives without creating another organization. Choose a project from the app switcher to focus the workspace, or choose **All projects** for an explicit organization-wide view. The selection is kept per browser tab and per organization, so two tabs can work in different projects without changing each other's context. ## What project context filters The active project filters project-owned lists before pagination, including: * conversations and their generated pages or artifacts; * datasets, jobs, heartbeats and heartbeat proposals; * job executions, workstreams and experiments; * agent runs, run analytics, and user-visible actions tied to project conversations; * event pipelines; * Activity items tied to project threads, jobs or workstreams; and * Attention items tied to project workstreams. New resources created while a project is selected are attached to that project. Pages generated from a conversation inherit that conversation's projects as well as the active project. ## Assigning existing resources to projects A resource can belong to any number of projects, and assignment is a lightweight link — adding or removing a resource from a project never modifies or deletes the resource itself. To manage assignments in bulk, open **Manage projects** from the project switcher and choose the **Assign resources** tab. Resources are grouped by type — pages, data, knowledge and conversations — with name search and a filter for resources assigned to a specific project or not assigned to any project. Click a project name on a resource row to assign it; click again to remove it. Assigning requires the contributor role or higher in the target project — organization admins and owners implicitly hold the project admin role in every project of their organization — so project names you cannot assign to appear disabled. Individual projects also accept resources from their own page: the **Add context** button on a project's **Work & context** tab adds data, segment-restricted data views, and conversations one at a time. Organization-level resources remain shared. These include integrations, agents, organization settings, shared Knowledge, the audit history, and standalone organization alerts or approvals. Selecting a project does not weaken or replace organization RBAC, and a direct resource URL still uses the resource's normal organization permissions. Project context groups and filters work; it is not a second tenant boundary. Use separate organizations when people or credentials must be isolated from one another. ## API, MCP and CLI List or create projects with `GET /v1/projects`, `POST /v1/projects`, `erdo_list_projects`, `erdo_create_project`, or `erdo project list/create`. Pass the selected project on subsequent API requests with `X-Project-ID`. Omit the header for **All projects**. See [API authentication and request context](/api/overview) and [MCP request context](/mcp/overview) for the complete header behaviour. # Quickstart Source: https://docs.erdo.ai/quickstart Connect a data source and get to a useful result in a few minutes. This walks through the shortest path to a real result: give Erdo some data, then ask Erdo to do something with it. ## 1. Create an account Sign up at [erdo.ai](https://erdo.ai/signup). You'll land in your organization's workspace — your team shares it, with permissions controlling who can see and do what. ## 2. Add some data In the sidebar, open **Setup → Data & connections**, and pick how to bring data in: * **Upload a dataset** — drop in a CSV or Excel file to get going immediately. * **Connect a source** — under **Data & connections**, link a database, Google Workspace, Slack, or another tool. Sign in and authorize; the connection is private to you and encrypted. Connecting Google Calendar here is also what enables Erdo to **book meetings** on a [voice call](/voice) later. ## 3. Ask for the outcome On **Home**, ask for what you want in plain language. Be specific about the outcome — Erdo figures out the steps. You do not need to choose or configure an agent first. > "From my `signups` dataset, show new signups by plan for last month and flag > anything that looks off." Erdo connects to your data, does the analysis, and shows the result — tables, charts, and its reasoning — right in the conversation. ## 4. Turn it into something durable From the same conversation, keep going: * **Build a page** — "Turn this into a dashboard I can share with the team." Erdo produces a live [page](/concepts#pages) wired to your data. * **Automate it** — have Erdo re-run on a schedule so the report refreshes on its own. See [Activity](/concepts#activity). ## 5. Stay in control As Erdo works, it will **propose** things — knowledge to remember, or actions that change data or reach outside Erdo. You will see decisions in **Activity** and approval prompts in the relevant conversation. Nothing consequential happens without your sign-off. See [Review and approvals](/concepts#review-and-approvals). *** ## Use Erdo outside the app You don't have to work in the web app, and you don't have to write code: * [MCP server](/mcp/overview) — connect Claude, Cursor, or any MCP client to your data * [CLI](/cli) — drive Erdo from your terminal or an AI coding session Building software on Erdo? The [REST API](/api/overview) and the [TypeScript](/ts-sdk/overview) / [Python](/sdk/invoke) SDKs are in the **Developers** tab. # The review queue Source: https://docs.erdo.ai/review-queue The queue of things an agent wants a human to decide — knowledge the critic proposes, investigations it opens, and the failure signals it keeps counting — readable and answerable over the app, MCP, REST, and the CLI. # The review queue As agents work they accumulate two kinds of loose end that a machine shouldn't close on its own. The first is a **proposal**: an agent learned something durable — "this column is the revenue metric", "this API caps page size at 100" — and wants it written into [Knowledge](/knowledge) so the next thread doesn't relearn it. The second is a **failure it couldn't fix in the moment**: an eval regressed, a run died on a runtime error, an automation broke. Neither should silently become truth or silently disappear, so both queue into **Review** — one place where a human accepts what's right, declines what isn't, and decides what to chase. The queue used to be reachable only from the web app. It is now the same surface over **MCP**, the **REST API**, and the **CLI**, so a headless caller — a scheduled agent, a CI job, your own automation — can read what's waiting and decide it without opening a browser. Every item is org-scoped and RBAC'd: you only ever see and decide your own organization's queue, exactly as the app enforces it. ## What lands in the queue Items fall into two families, and the queue keeps the **decision items above the failure signals** so a proposal waiting on you is never buried under noise, whatever their relative priority. **Decision items** are the ones an agent is explicitly asking a human to rule on: * A **knowledge patch** — a new or edited Knowledge object the agent proposes. Because approving one changes what every agent and person in your workspace treats as true, it carries the whole proposed body: the target object, the new title and markdown, the problem it fixes, the evidence behind it, and the impact it expects. You read that before you apply it, and applying it is the same human-in-the-loop step as an [approval](/approvals) on an action — the difference is that here the thing being approved is a fact, not a side effect. * An **investigation** — a request for an engineer to dig into a root cause and come back with a code fix or a concrete finding, carrying links to the run, thread, and eval that motivated it. **Failure signals** are raised automatically when work breaks — a failed eval case, a failed top-level run, a failed automation, or answer feedback. These **deduplicate**: repeated failures of the same class count *up* on one item (its `occurrence_count`) rather than spawning a fresh row every time, so a persistently broken path reads as one loud item instead of a hundred quiet ones. A signal resolves when the underlying condition clears — an eval-failure item closes once the same case passes again — or ages out of the queue if it goes stale. ## Deciding an item A decision is one of four actions: * **Apply** — for a knowledge patch only: create or update the proposed Knowledge object, then close the item as resolved. The apply is atomic — two callers racing to apply the same patch can't produce a duplicate object, and if the Knowledge write fails the item reopens so you can retry. * **Resolve** — close the item as handled without applying a patch (the right action for a failure signal you've dealt with elsewhere, or an investigation you've completed). * **Reject** — close the item as declined. A rejected patch is not written to Knowledge. * **Snooze** — hide the item until later. It takes an optional number of minutes and defaults to seven days, after which it returns to the open queue. You can attach a **note** to any decision, which is recorded on the item as the resolution note. ## Programmatic access The queue is readable and answerable over MCP, REST, and the CLI. A review item has no slug, so it's referenced by its **id** — you list the queue, read the one you care about, and decide it by that id, all within the one surface. ```bash theme={null} # read the queue erdo reviews list # the open queue (decision items first) erdo reviews list --type knowledge_patch # only proposed knowledge erdo reviews list --status resolved # a different status; '' for all # read one item's full payload (the proposed patch body for a knowledge_patch) erdo reviews show # decide it erdo reviews decide --apply # apply a knowledge patch, then resolve erdo reviews decide --resolve --note "handled in PR #123" erdo reviews decide --reject erdo reviews decide --snooze 1440 # snooze for 24 hours ``` ### MCP tools | Tool | What it does | | ------------------------- | ---------------------------------------------------------------- | | `erdo_list_review_items` | List the queue, filtered by `status` (default `open`) or `type`. | | `erdo_get_review_item` | Get one item with its full payload (the proposed patch body). | | `erdo_decide_review_item` | Decide an item: `apply`, `resolve`, `reject`, or `snooze`. | ### REST Base URL `https://api.erdo.ai`. `Authorization: Bearer ` + `X-Organization-ID`. | Method | Path | | ------ | ----------------------------- | | `GET` | `/v1/review-items` | | `GET` | `/v1/review-items/:id` | | `POST` | `/v1/review-items/:id/decide` | `GET /v1/review-items` takes optional `status` (default `open`; pass `status=` for all), `type`, `limit`, and `offset` query parameters. `POST /v1/review-items/:id/decide` takes `action` (`apply`, `resolve`, `reject`, or `snooze`), an optional `minutes` (for `snooze`), and an optional `note`. # Scheduled actions Source: https://docs.erdo.ai/scheduled-actions Declare reads and judgements to run on a clock, bounded by the provider objects a workstream governs — never a hand-authored heartbeat script. Some work doesn't start because a row landed. It starts because it's eight in the morning: a daily read of an ad account's spend, a weekly pacing check against a budget, a conversion reconciliation before anyone is at their desk. A **scheduled action** declares exactly that — the reads to make, what to do with what they returned, and where to put the answer — and Erdo owns everything underneath: the automation, its trigger, and the schedule. [Row actions](/dataset-row-actions) answer the same question for work that starts when data arrives. This is the other half, for work that starts because of the clock. The two share a step vocabulary on purpose — an author who has declared one can read the other — but they differ in two places the trigger changes the meaning, and both differences matter more than they look. ## Every step runs A row action is a chain of alternatives: the first one to answer wins, because looking a person up in a second place is pointless once the first place found them. A scheduled action is the opposite. It's usually several queries whose results are **all** needed — spend from one read, budget from another, a threshold that compares the two — and a step that stopped running because an earlier one succeeded would silently remove a threshold's only source. So every step in a scheduled action is **named**, every one of them runs, in the order declared, and each one's result is kept under its name — `steps.performance`, `steps.budgets` — for the steps that come after it. That naming is checked, not just conventional. Every `steps.` a script mentions must name a step declared **before** it in the same declaration — checked when you `PUT` the declaration, not discovered the first time it runs. This exists because it already went wrong quietly: three thresholds — budget pacing and two impression-share checks — sat in a live, hand-authored record for weeks with no query supplying them. Nothing failed. Nothing looked broken. The script read `steps.budgets`, got `undefined`, compared a number against it, and concluded nothing, every single morning, until an audit noticed. Declaring the same thing here rejects it before it can run once: ```json theme={null} { "error": "step \"pacing\" reads steps.budgets in its script, but the steps declared before it are performance. A step that reads one that never ran evaluates to nothing without failing, which is how a threshold stops being checked without anybody noticing." } ``` The same check catches the more realistic version of the mistake: the declaration was correct, and later somebody edited it and deleted the read a threshold depended on. That edit fails to save, rather than shipping a threshold that silently stopped being checked. ## Scope comes from the workstream, read fresh every run A scheduled action names a `workstream`, and what that workstream currently governs — its [`external_refs`](/strategies#declare-which-provider-campaigns-a-workstream-governs) — is what bounds the run. Nothing about that scope is copied into the declaration. Every run reads the workstream's refs as of *that run*, and hands them to the steps as `{{scope.ids}}` (a flat list of provider ids, the shape almost every query wants as the right-hand side of an `IN (...)`) and `{{scope.refs}}` (the full objects, for when a step needs more than the id). This is the entire reason the resource exists. What it replaces is a campaign allowlist typed into a knowledge record — the record itself called it "a manually maintained mirror" of a mapping the job "cannot read." It drifted exactly the way a hand-kept mirror does: it kept driving recommendations for a campaign that had been wound down eight days earlier, and a discovery rule written to route around that problem pulled a *different* property living in the same ad account into scope instead. One Google Ads account has held two separate developments at once — an unfiltered read of that account, or a stale one, reaches the other customer's campaigns. Reading the refs fresh at run time closes that gap by construction. Add a campaign, or a whole development, to a workstream's scope, and the very next run picks it up — no edit to the declaration, nothing redeployed. Remove one, and the next run stops reading it. The mapping lives in exactly one place. ### An empty scope means no run, not an unfiltered one A workstream that governs nothing yet — a campaign whose provider ids haven't resolved, a development just getting started — is a legitimate, honest state. When that's what a run reads, the run does **no work** and says so: ```json theme={null} { "summary": "daily-optimizer: nothing in scope — maurice-newdev-optimization governs no provider objects, so no read was run.", "in_scope": 0, "steps_run": 0, "rows_written": 0 } ``` It does not fall back to running its queries with the filter removed. That fallback is the actual danger being defended against: a GAQL query with `campaign.id IN ({{scope.ids}})` rendered against an empty list is not "match everything," it's a query that would either fail or, worse, silently match nothing — but a step that instead *dropped* the filter because scope came back empty would read the whole account, including whatever else lives in it. So scope is checked before a single step runs, and reading it at all is itself load-bearing: a workstream that names a scope and can't be read fails the run outright rather than proceeding as if it had none. A declaration can omit `workstream` entirely, but only for one that genuinely acts on no provider objects — a run that reconciles something org-wide, say. For anything reading a provider account, name the workstream. ## Declaring one `PUT /v1/scheduled-actions` takes a name, a workstream, a cron schedule, and the steps. Like a row action, the declaration **replaces** whatever was previously declared under the same name. ```bash theme={null} curl -X PUT "https://api.erdo.ai/v1/scheduled-actions" \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "daily-optimizer", "workstream": "2200brickell-search-optimization", "schedule": "0 8 * * *", "timezone": "America/New_York", "steps": [ { "name": "performance", "app": "google_ads", "key": "query_google_ads", "input": { "customer_id": "8834039525", "query": "SELECT campaign.id, campaign.name, metrics.cost_micros, metrics.conversions FROM campaign WHERE campaign.id IN ({{scope.ids}}) AND segments.date DURING YESTERDAY" }, "result_path": "results" }, { "name": "budgets", "app": "google_ads", "key": "query_google_ads", "input": { "customer_id": "8834039525", "query": "SELECT campaign.id, campaign_budget.amount_micros FROM campaign_budget WHERE campaign.id IN ({{scope.ids}})" }, "result_path": "results" }, { "name": "pacing", "script": "var findings = []; var budgetByCampaign = {}; steps.budgets.forEach(function (b) { budgetByCampaign[b[\"campaign.id\"]] = b[\"campaign_budget.amount_micros\"] / 1e6; }); steps.performance.forEach(function (p) { var budget = budgetByCampaign[p[\"campaign.id\"]]; var spend = p[\"metrics.cost_micros\"] / 1e6; var ratio = budget ? spend / budget : null; if (ratio !== null && ratio < 0.5) { findings.push({ campaign_id: p[\"campaign.id\"], campaign_name: p[\"campaign.name\"], spend: spend, budget: budget, ratio: ratio, issue: \"underpacing\" }); } }); return findings;" } ], "destination": { "dataset": "2200-brickell.optimizer-findings", "key_column": "campaign_id", "columns": { "campaign_id": "{{item.campaign_id}}", "campaign_name": "{{item.campaign_name}}", "issue": "{{item.issue}}", "pacing_ratio": "{{item.ratio}}", "workstream": "{{scope.workstream}}", "checked_at": "{{now}}" } } }' ``` The `customer_id` each step targets comes from [`GET /v1/ad-accounts`](/ad-accounts) — address it to an operating account, never a manager (MCC). A manager holds no campaigns of its own, so a query aimed at one returns empty rather than failing, and nothing here would catch a pacing check reading that silence as "nothing is underpacing" rather than "this queried the wrong account." Two `query_google_ads` reads gather yesterday's spend and each campaign's budget, both filtered to the campaigns `2200brickell-search-optimization` governs today — never a list written into the declaration. The `pacing` step is a script: it joins the two reads by campaign id and decides, in code, which campaigns are underpacing. That decision — join two result sets, compute a ratio, apply a threshold — has no template that expresses it; a script is what a declaration reaches for once a step needs to reason about what an earlier step returned rather than just relay it. `GET /v1/scheduled-actions?name=` reads one declaration back — its steps, schedule, workstream, and the automation's job id, so you can pull its run history through the automation surfaces. `GET /v1/scheduled-actions` lists every declaration in your organization. Both read state off the automation itself, never a second copy. Passing `"enabled": false` **removes** the declaration, the same as a row action and for the same reason: a disabled automation would keep the state its last run left, and re-enabling it months later would resume from a position describing a world that has moved on. There is no pause — declare it again with the schedule you want when you're ready to resume. ## The schedule `schedule` is a five-field cron expression — `"0 8 * * *"` for every day at eight. `timezone` is the IANA zone it's read in, defaulting to UTC. Set it explicitly for anything comparing against a provider's own day boundary: an ad account reports "yesterday" in the account's own timezone, and a run fired at 08:00 UTC with no timezone set is still the previous afternoon in Miami — a pacing check reading "yesterday's spend" would be reading the wrong day. Updating a declaration's schedule replaces the trigger, not just the stored value — an edit that changed the time and left the old trigger running would be the sort of half-applied change nobody would think to look for. ## Steps A step is an action to invoke (`app` + `key`) or a `script`, never both — the same rule as a row action. Everything a row-action step supports on the action side carries over here: `input` with `{{placeholder}}` templates, `result_path` to select what an action's envelope actually contains, and `for_each` to fan one action out over a list (most usefully `{{scope.refs}}`, one invocation per governed object). What's different is the vocabulary a script sees. A row-action script judges one row; a scheduled-action script receives `(scope, steps, run)` — the objects this run may act on, every prior step's result addressed by name, and `{now, budget, invocations}` describing the run so far. It shapes what the reads returned: it cannot invoke an action or read a dataset directly, because the declaration itself has to say what it calls and where it writes — that's what makes "what can this automation touch" something you can read off the declaration instead of something you'd have to read the generated code to know. ## The destination `destination` records what a run produced, the same shape as a row action's: a `dataset`, an optional `key_column` to upsert on, and `columns` mapping each column to a template. The one behavior specific to scheduled actions: **when the last step returns a list, the run writes one row per element.** A pacing check that found three underpacing campaigns writes three rows, each with its own `{{item}}` bound to one finding — that's how a set of things worth looking at becomes a set of rows someone can filter and sort, rather than one blob a person has to parse. Any other shape — the last step returned a single object, or nothing — writes at most one row. Omit `destination` entirely for a declaration whose steps act rather than report (pausing a campaign, sending an alert) — there's nothing to record. Templates in `destination.columns` resolve against `{{scope...}}`, `{{steps....}}`, `{{result...}}` (the last step's value), `{{item}}` and `{{item_index}}` when writing one row per list element, and `{{now}}`. ## Limits A declaration may list up to **12 steps**. One run makes at most **10 action invocations** across all of them — the same budget row actions share, so a `for_each` over a large scope is the thing to narrow rather than the step count. A run writes at most **200 rows**; anything beyond that is reported as not recorded rather than silently dropped. A script step is capped at 20,000 characters, and a declaration name at 60. A run in which **every** invocation failed doesn't complete quietly — it fails outright, the same reasoning as a row action: reporting success while nothing actually worked would leave a broken declaration green in every automation view while it does nothing every morning. ## No MCP write tool, deliberately Reading a scheduled action's declaration is available as an MCP tool (`erdo_get_scheduled_actions`), so an agent can see what's already scheduled and what it's bound to. **Declaring one is REST-only.** There is no `erdo_set_scheduled_action` tool, and that asymmetry is the point: the entire safety property this resource provides is that a run's scope comes from the workstream, not from anything the automation itself can ask for. An agent that could declare its own scheduled work could hand that work a wider scope than it was ever given — which is exactly the failure moving scope out of hand-authored prose was meant to remove. Declaring a scheduled action is something the system that holds the evidence for a workstream's scope does, over `/v1`, the same way a workstream's `external_refs` are written. ## Cost Nothing to declare or hold idle. The automation is a deterministic script fired by a cron trigger — no LLM, and no agent reasoning about whether to run. The reads and any action a step invokes are what cost anything, and only when the schedule fires. Don't hand-author this as a recurring agent or a polling heartbeat instead. A five-minute heartbeat doing a job like this once cost a customer roughly \$81 in a single week to check a couple of numbers once a day. A scheduled action runs only on the cadence you declared, at no per-tick cost beyond the reads it actually makes. ## Related The event-triggered counterpart — work that starts when rows land, not when the clock strikes. How a workstream declares the provider campaigns it governs — the `external_refs` a scheduled action's scope is read from. The run history of the automation behind a scheduled action. Standing policies that let an automation invoke an action unattended. # Python SDK — Invoke Source: https://docs.erdo.ai/sdk/invoke Execute agents programmatically with the Python SDK # Invoke The `invoke()` function allows you to execute agents programmatically from Python code, making it easy to test agents, integrate them into applications, and automate workflows. ## Configuration Before invoking agents, configure the SDK with your credentials: ```python theme={null} import erdo erdo.setup( endpoint="https://api.erdo.ai", auth_token="your-api-token", organization="your-org-slug" # or organization UUID ) ``` Configuration can also be set via environment variables: ```bash theme={null} export ERDO_ENDPOINT="https://api.erdo.ai" export ERDO_AUTH_TOKEN="your-api-token" export ERDO_ORGANIZATION="your-org-slug" ``` Or via `~/.erdo/config.yaml`: ```yaml theme={null} endpoint: https://api.erdo.ai auth_token: your-api-token organization: your-org-slug ``` **Priority order:** `erdo.setup()` > environment variables > config file ## Quick Start ```python theme={null} import erdo from erdo import invoke # Configure SDK (or use env vars / config file) erdo.setup( endpoint="https://api.erdo.ai", auth_token="your-api-token", organization="my-org" ) # Simple invocation response = invoke( "my-agent", input="Hello!", ) print(f"Success: {response.success}") print(f"Result: {response.result}") ``` ## Basic Usage ### Invoke with Input ```python theme={null} from erdo import invoke response = invoke( "data-question-answerer", input="What were Q4 sales?" ) if response.success: # Print assistant messages for msg in response.messages: print(msg["content"]) else: print(f"Error: {response.error}") ``` ### Invoke with Datasets ```python theme={null} response = invoke( "data-question-answerer", input="Show me the top products", datasets=["sales-q4-2024", "products-catalog"] ) ``` ### Invoke with Parameters ```python theme={null} response = invoke( "data-analyzer", input="Analyze the data", parameters={ "analysis_type": "trend", "time_period": "monthly" } ) ``` ## Streaming Stream events in real-time as the agent executes: ```python theme={null} response = invoke( "my-agent", input="Analyze this dataset", stream=True, output_format="text" ) # Output streams to stdout automatically as events arrive # Final result available in response after completion ``` Stream with verbose step tracking: ```python theme={null} response = invoke( "my-agent", input="Process this data", stream=True, output_format="text", verbose=True ) # Output includes step-by-step progress: # ▸ erdo.data-analyst (agent) # ✓ erdo.data-analyst # The analysis shows that... ``` ## InvokeResult The `invoke()` function returns an `InvokeResult` object: ```python theme={null} class InvokeResult: success: bool # Whether invocation succeeded agent_key: Optional[str] # Agent key invocation_id: Optional[str] # Unique invocation ID result: Optional[Dict] # Terminal result event {status, message, error} messages: List[Dict[str, Any]] # Visible text messages from the agent steps: List[Dict[str, Any]] # Step execution info {step_id, key, name, type, status} events: List[Dict[str, Any]] # Complete raw event stream for debugging error: Optional[str] # Error message if failed ``` ### Understanding the Result Structure The `result` field contains the terminal event from the agent service: ```python theme={null} { "status": "success", # "success" or "error" "message": "...", # Error message (only if status is "error") "error": "timeout" # Error type (only if status is "error") } ``` The `messages` field contains visible text output from the agent — this is typically what you want to display to users. ### Example Usage ```python theme={null} response = invoke("my-agent", input="What were Q4 sales?") if response.success: # Print agent's text output for msg in response.messages: print(msg["content"]) # Access step execution info print(f"\nSteps ({len(response.steps)}):") for step in response.steps: print(f" ✓ {step['key']} ({step['type']})") # Get invocation ID print(f"\nInvocation: {response.invocation_id}") # Access raw events for debugging print(f"Events: {len(response.events)} raw events") else: print(f"Error: {response.error}") ``` ## Complete API Reference ```python theme={null} def invoke( agent_key: str, input: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None, datasets: Optional[List[str]] = None, stream: bool = False, output_format: str = "events", verbose: bool = False, print_events: bool = False, **kwargs ) -> InvokeResult ``` ### Parameters | Parameter | Type | Default | Description | | --------------- | ------ | ---------- | ------------------------------------------------------------- | | `agent_key` | `str` | required | Agent key (e.g., `"data-question-answerer"`) | | `input` | `str` | `None` | User input string | | `parameters` | `dict` | `None` | Parameters to pass to the agent | | `datasets` | `list` | `None` | Dataset slugs to include (e.g., `["sales-2024"]`) | | `stream` | `bool` | `False` | Stream events in real-time | | `output_format` | `str` | `"events"` | `"events"` (raw), `"text"` (formatted), or `"json"` (summary) | | `verbose` | `bool` | `False` | Show step execution details (text format only) | | `print_events` | `bool` | `False` | Print all raw events as they arrive | ### Keyword Arguments * `endpoint` (str): Custom API endpoint * `auth_token` (str): Custom auth token ## Examples ### Data Analysis ```python theme={null} response = invoke( "data-question-answerer", input="What were Q4 sales by region?", datasets=["sales-2024"], parameters={ "time_period": "Q4", "group_by": "region" } ) if response.success: for msg in response.messages: print(msg["content"]) print(f"\nExecuted {len(response.steps)} steps:") for step in response.steps: print(f" ✓ {step['key']} ({step['type']})") ``` ### Batch Processing ```python theme={null} from concurrent.futures import ThreadPoolExecutor def process_query(query): return invoke( "data-analyzer", input=query, datasets=["my-dataset"] ) queries = ["Query 1", "Query 2", "Query 3"] with ThreadPoolExecutor(max_workers=3) as executor: results = list(executor.map(process_query, queries)) for i, result in enumerate(results): if result.success: for msg in result.messages: print(f"Query {i+1}: {msg['content']}") ``` ### Integration with Flask ```python theme={null} from flask import Flask, request, jsonify from erdo import invoke app = Flask(__name__) @app.route('/analyze', methods=['POST']) def analyze(): data = request.json response = invoke( "data-analyzer", input=data["query"], datasets=data.get("datasets", []), parameters=data.get("parameters"), ) if response.success: return jsonify({ "success": True, "messages": response.messages, "result": response.result, "steps": response.steps, "invocation_id": response.invocation_id, }) else: return jsonify({ "success": False, "error": response.error, }), 400 if __name__ == "__main__": app.run() ``` ### Error Handling ```python theme={null} from erdo import invoke try: response = invoke( "my-agent", input="Hello" ) if response.success: for msg in response.messages: print(msg["content"]) else: # Agent returned an error print(f"Agent error: {response.error}") except Exception as e: # Network or other error print(f"Invocation failed: {e}") ``` ## Best Practices ### 1. Always Check Success ```python theme={null} response = invoke("my-agent", input="...") if response.success: for msg in response.messages: print(msg["content"]) else: print(f"Error: {response.error}") ``` ### 2. Stream Long-Running Agents ```python theme={null} # Good for long-running agents — see progress in real-time response = invoke( "long-agent", input="Process large dataset", stream=True, output_format="text" ) ``` ### 3. Use Appropriate Output Format ```python theme={null} # For humans — prints to stdout as agent runs response = invoke("my-agent", input="...", output_format="text") # For integration/parsing — structured data response = invoke("my-agent", input="...", output_format="json") # For custom processing — raw events response = invoke("my-agent", input="...", output_format="events") ``` ## Troubleshooting ### Authentication Errors Configure the SDK with valid credentials: ```python theme={null} import erdo erdo.setup( endpoint="https://api.erdo.ai", auth_token="your-api-token", organization="your-org-slug" ) ``` Or use the CLI: ```bash theme={null} erdo login ``` Or set environment variables: ```bash theme={null} export ERDO_ENDPOINT="https://api.erdo.ai" export ERDO_AUTH_TOKEN="your-token" export ERDO_ORGANIZATION="your-org-slug" ``` ### Import Errors Install the SDK: ```bash theme={null} pip install erdo # or uv pip install erdo ``` # Email sending domains Source: https://docs.erdo.ai/sending-domains Have Erdo send outreach from an address on your own domain — verify the domain with a few DNS records, then every follow-up an agent sends comes from your brand rather than Erdo's. # Email sending domains When an agent follows up with a lead, that mail goes out from Erdo's own address by default. The recipient sees a sender they have never heard of, on a domain that has nothing to do with the business they enquired with — which is bad for replies and worse for the spam filter, because nothing about the message lines up with the site they just filled a form on. A **sending domain** fixes that at the source: verify a domain you own, and outreach leaves as `hello@acme.com`, signed off in your brand's name. Verification is entirely DNS — a few records at whichever host runs your domain, the same motion you already went through for a [custom pages domain](/custom-domains). No mail server to run, no mailbox to hand over, no credentials to give Erdo. Only **outreach** moves to your domain: the mail agents and automations send on your behalf — lead follow-ups, replies to enquiries, alerts an [automation](/automations) sends out. Erdo's own product mail — team invites, account notifications — keeps coming from Erdo, because that mail is *from Erdo* and dressing it up as your brand would be misleading rather than helpful. ## Choose the name to send from You can verify an apex domain (`acme.com`) or any subdomain (`hello.acme.com`, `news.acme.com`) — Erdo does not require the name to match your pages domain, and does not care whether you host anything on it. An organization has **one sending domain**: everything Erdo sends on your behalf leaves from one identity, so which domain that is should never be ambiguous. To switch domains, remove the current one (sends fall back to Erdo's address) and register the new one — so the name is worth choosing deliberately. A **subdomain is usually the better choice**, for one reason worth understanding before you pick. Once you verify a domain, the reputation of everything Erdo sends attaches to *that* name. Send from the apex and automated outreach shares a reputation with the mail your team sends by hand from their own mailboxes; a bad week for one is a bad week for the other. A subdomain keeps them separate, so a deliverability problem on outreach never touches your everyday business mail. We suggest `hello.` — it reads like a place a person writes from, which is the point of sending as your own business. Anything warm works (`team.`, `contact.`); avoid names that read as bulk mail (`marketing.`, `promo.`), and avoid `send.`, which collides with the name Erdo's return-path records already use. If you pick `hello.`, give the mailbox a different name — `sales@hello.acme.com` reads well, `hello@hello.acme.com` does not. There is also a hard constraint if you want Erdo to **receive** at the name — which is what brings replies back in, so an agent can read what a lead wrote and act on it rather than only your team seeing it. Receiving needs an MX record on the name itself, and a name can only have one set of MX records — if `acme.com` already points its MX at Google Workspace or Microsoft 365, it cannot also deliver to Erdo. Erdo checks for existing MX records when you register a domain, and a name that already routes mail somewhere is registered **sending-only**: no MX record appears among the ones to add, so nothing you (or the DNS admin you email the instructions to) could paste would take your mailbox down. In that setup a forwarding mailbox is required, since replies need somewhere real to land. To also receive through Erdo, verify a subdomain such as `hello.acme.com` instead, which has no mailbox MX of its own to displace. ## Set it up Open **Settings → Domains**, go to **Email sending**, and add the domain you want to send from. Erdo registers it and immediately hands back the DNS records to create. Add them at whatever host answers for your domain — your registrar, Cloudflare, Route 53. Every record is shown with a copy button, and the values must be entered exactly as given. If someone else runs your DNS, email them the instructions straight from the domain's card — the whole record set arrives in one message, which is safer than pasting values into chat one at a time. Erdo re-checks the domain roughly every ten minutes until every record is visible and correct, then marks it **active**. DNS changes usually surface within minutes, but a host with long TTLs can take a few hours — nothing is wrong until the records are live and Erdo still can't see them. Choose the display name, the local part of the address (`hello` unless you change it), and the mailbox replies should reach. These are editable at any time and take effect on the next send. ## The records, and what each one is for Erdo generates the exact values for your domain; there is nothing to compose yourself. What they do: | Record | Where it goes | Why it's needed | | ------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **DKIM public key** (TXT) | A selector Erdo names, on your domain | Lets receiving servers verify that a message signed with Erdo's key really was authorised by your domain. This is the record that makes the mail *yours*. | | **Return path** (MX) | A `send.` subdomain of the name you verified | Routes bounces and delivery reports back through a name inside your domain, which is what makes SPF align with the address in the From header. | | **SPF** (TXT) | The same `send.` subdomain | Names the servers allowed to send with that return path. | | **DMARC policy** (TXT) | `_dmarc` on your domain | Offered only when your domain has no DMARC policy at all. Erdo suggests a monitoring-only policy (`v=DMARC1; p=none;`) — it changes nothing about how your mail is handled and starts the reports you'd need before tightening it later. | Because the return path sits on a `send.` subdomain rather than on the name itself, these records do not touch your existing mail routing: adding them cannot divert mail to your team's mailboxes. Once the domain is active, **DMARC alignment is automatic**. Every message is sent from an address on the verified domain, signed with a DKIM key published by that domain, with a return path inside it — so both the DKIM and SPF checks align with the visible From address, which is what a DMARC policy actually asks for. You get this by construction; there is no setting to enable. ## What changes once it's active Outreach switches to `@` with your display name, and the sign-off at the bottom of composed messages changes from Erdo's to your brand's, so nothing in the message contradicts the sender. What happens when someone replies depends on whether you turned receiving on, because that is the difference between the reply going somewhere Erdo can see and going somewhere it cannot. With **receiving on**, the mail goes out with **Reply-To** set to the sending address itself — which is a name that now delivers to Erdo — so a reply lands in two places at once. It is stored against the domain, where an agent can read it and act on it — answer the question, book the meeting, note the objection against the lead — and a copy is forwarded straight to the mailbox you nominated, so your team reads it in the inbox they already live in rather than logging into Erdo to find out someone wrote back. On that forwarded copy the **Reply-To** is set to the person who actually wrote, not to Erdo: hit reply in your own mailbox and your answer goes directly to the lead, from your real address, with Erdo out of the loop entirely. Nothing is trapped in Erdo waiting for you to notice it. With **receiving off** — which is the right choice when the name already routes mail to Google Workspace or Microsoft 365 — nothing changes about where mail lands. Outreach carries a **Reply-To** pointing at the mailbox you nominated, so someone who hits reply reaches a real person at your company directly. That reply never passes through Erdo, so Erdo has no record of it and no agent can act on it. Delete a sending domain and nothing breaks: outreach falls straight back to Erdo's default sender on the next send, and replies to the old address stop reaching Erdo. ## Deliverability is now your domain's This is the trade you are making, and it is worth being explicit about. On Erdo's shared sending domain your mail inherits a reputation that already exists. On your own domain you start from nothing, and everything Erdo sends builds — or damages — the reputation of a name you also use for everything else. Practically, that means: * **Warm up gradually.** A domain that has never sent automated mail and then emits a few hundred messages in a day looks exactly like a compromised domain to a spam filter. Start with the follow-ups you'd send anyway and let volume grow over days, not in one afternoon. * **Keep the volume modest.** Erdo caps an organization at **50 distinct recipients per day** across all its outbound mail, and verifying a domain does not raise that cap. It is a deliberate guard: outreach from an agent is meant to be a handful of relevant follow-ups, not a campaign send. * **Mail people who asked to hear from you.** Follow-ups to leads who filled in your form perform well and get few complaints. Cold lists on a freshly verified domain are the fastest way to teach the receiving side to distrust your name. * **Leave DMARC on `p=none` for a while.** If Erdo offered you the DMARC record, it deliberately suggests monitoring rather than enforcement, so that a misconfiguration somewhere else in your mail setup doesn't start bouncing legitimate mail the day you add it. ## API Sending domains are org-scoped and require an **org admin**, like [custom domains](/custom-domains). A domain is addressed by its name — the natural identifier — and a name registered to another organization answers `404` with no hint that it exists. List the org's sending domains with their live status and DNS records: ```bash theme={null} curl https://api.erdo.ai/v1/sending-domains \ -H "Authorization: Bearer $ERDO_API_KEY" ``` Register one (the response carries the records to create): ```bash theme={null} curl -X POST https://api.erdo.ai/v1/sending-domains \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "hello.acme.com"}' ``` Read one back while you wait for DNS to propagate: ```bash theme={null} curl https://api.erdo.ai/v1/sending-domains/hello.acme.com \ -H "Authorization: Bearer $ERDO_API_KEY" ``` Remove a registration — outreach reverts to Erdo's default sender: ```bash theme={null} curl -X DELETE https://api.erdo.ai/v1/sending-domains/hello.acme.com \ -H "Authorization: Bearer $ERDO_API_KEY" ``` Read the mail that arrived at the domain, newest first — usually replies to outreach, though anything sent to an address on the domain lands here too. This one needs only org membership rather than an admin, because received mail is correspondence to read and not a registration to change — and `domain` can be left off, since you have one sending domain and the read resolves it: ```bash theme={null} curl "https://api.erdo.ai/v1/received-emails?limit=20" \ -H "Authorization: Bearer $ERDO_API_KEY" ``` Each message comes back as `{from_email, from_name, subject, text_preview, received_at, forwarded_at}`. `text_preview` is the opening of the message — enough to separate a real answer from an out-of-office without pulling whole bodies. `forwarded_at` says when the copy went to your nominated mailbox; empty means the message is in Erdo only, so nobody on your side has necessarily seen it yet. A sending-only domain returns an empty list, which is the configuration working as chosen rather than a fault. ### MCP tools | Tool | REST endpoint | Method | | ---------------------------- | ----------------------------- | ------ | | `erdo_create_sending_domain` | `/v1/sending-domains` | POST | | `erdo_list_sending_domains` | `/v1/sending-domains` | GET | | `erdo_get_sending_domain` | `/v1/sending-domains/:domain` | GET | | `erdo_update_sending_domain` | `/v1/sending-domains/:domain` | PATCH | | `erdo_delete_sending_domain` | `/v1/sending-domains/:domain` | DELETE | | `erdo_list_received_emails` | `/v1/received-emails` | GET | ## Troubleshooting | You see | What to do | | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The domain sits waiting for DNS | Confirm the records exist at the host that actually answers for the domain, and that no record name was doubled up (many DNS panels append the domain themselves, so pasting the full name yields `send.acme.com.acme.com`). Erdo re-checks about every ten minutes. | | A warning that the domain already has MX records | The name is already receiving mail elsewhere — Google Workspace, Microsoft 365, your own server — so Erdo registered it sending-only and issued no MX record. To also receive replies through Erdo, remove it and register a subdomain like `hello.acme.com` instead. | | Replies aren't appearing in Erdo | Replies only come back when the domain was registered with receiving on and its MX record is live — check the domain's records, and remember a name that already routes mail elsewhere cannot also deliver to Erdo. A sending-only domain sends replies straight to your nominated mailbox and Erdo never sees them. | | A reply is in Erdo but never reached your mailbox | Forwarding needs a nominated mailbox on the domain; without one, replies are stored in Erdo only. Bounce notifications are deliberately not forwarded. | | Verification succeeded but mail still comes from Erdo | Outreach picks up the domain once it is **active**; a domain still verifying doesn't change any sender. Product mail — invites, notifications — never switches. | | No **Email sending** section in Settings → Domains | The capability isn't enabled for your organization yet; ask your Erdo admin. | ## Related The same motion for your published pages — serve them from your own hostname. The record of everything Erdo has sent for you, including the address it sent from. Where most outreach is sent from. An address Erdo owns and can receive at, for agents that need to read mail. # Sent email Source: https://docs.erdo.ai/sent-email Every email Erdo sends on your behalf is recorded — who it went to, what it said, whether it landed, and what it was about. Read it back over the API. Erdo sends email for you all the time: an agent mails a weekly summary, an event pipeline alerts your sales desk the moment a lead lands, an automation sends a digest. Each of those is recorded as it happens, and the **sent email log** is how you read that record back. This matters most when something is automated. An alert that fires at 2am is invisible unless you can go and look at it afterwards — and "did it actually go out, and what did it say?" is the first question anyone asks when a notification seems to have gone missing. The log answers it directly, scoped to your own organization, so you can show it in your own product rather than sending people to a mail provider's dashboard. ## What a record holds Every audited send stores the recipient and display name, the subject, both the plain-text and HTML bodies exactly as they were sent, the delivery outcome, and the timestamps. The **status** walks the whole delivery story, not just our side of it. `pending`, `sent`, and `failed` are recorded at the moment of sending — `sent` means the mail provider accepted the message, which says nothing about arrival. The rest comes back from the provider as it happens: `delivered` (the receiving server took it), `delayed` (it is being deferred — a greylist, a full mailbox), `bounced` (it can never arrive), and `complained` (the recipient marked it as spam). A bounce or a failure also records **why** — "the recipient's mailbox does not exist" is the difference between a status you can act on and a label — and a bounced or complained address is one your automations should stop mailing. That includes suppression: after a hard bounce or complaint the mail provider refuses further sends to that address on its own, and such a send records `failed` with the suppression as its reason rather than pretending it went out. Mail sent before delivery tracking existed stays at `sent`, so `sent` must never be read as "not delivered". It also stores the **address the message went out from**, and the reply address that went with it. That is worth recording rather than assuming, because the sender is no longer fixed: an organization that has verified an [email sending domain](/sending-domains) has its agent outreach sent from its own address, while Erdo's product mail keeps coming from Erdo. Reading the from address back off the record is how you tell, for any given message, which one it was — and how you can see a domain take effect without waiting for someone to forward you a copy. It also stores **context** — a small set of key/value tags describing what the message was *about*. This is the part that makes the log genuinely queryable, and it exists because the recipient is very often not the subject. A new-lead alert is addressed to your sales desk, so filtering by recipient tells you nothing about which lead it concerned. Tag the send instead: ```json theme={null} { "kind": "lead_alert", "lead_email": "ana@example.com" } ``` and "every email we've sent about Ana" becomes a real query rather than a substring search over subject lines that breaks the moment someone rewords a template. Context is never shown to the recipient. Context values are flat text. Nested objects and arrays are dropped rather than stored, because the lookup that reads them back matches on flat key/value pairs — keeping them would advertise a query that could never work. ## Reading the log `GET /v1/emails` lists your organization's mail, newest first, filtered by recipient (`to`), by time (`since`, an RFC 3339 timestamp), or by subject text (`search`). Bodies are omitted from the listing so a page of results doesn't drag a page of HTML documents with it. ```bash theme={null} curl "https://api.erdo.ai/v1/emails?to=desk@example.com&limit=25" \ -H "Authorization: Bearer $ERDO_API_KEY" ``` To filter by context, use `POST /v1/emails-query` — a query string can't carry a key/value map, so the structured probe travels in a JSON body. The probe is a **containment** match: name only the keys you care about and rows carrying those plus others still match. ```bash theme={null} curl -X POST "https://api.erdo.ai/v1/emails-query" \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"context": {"lead_email": "ana@example.com"}}' ``` `GET /v1/emails/{emailID}` returns one message in full, including both bodies as they were sent. Listings report a `total` alongside the page, so you can page without having to discover the end by requesting an empty result. Agents in the product read the same log with the `list_sent_emails` tool, so "did the follow-up to that lead go out?" is a question you can ask in chat — the agent checks the record rather than its own memory, which also covers sends made by automations and other conversations. ## Tagging what you send The `send_email` action takes an optional `context` object, wherever you invoke it — from an agent, from a `script.run` step on an event pipeline, or from a scripted job: ```js theme={null} actions.invoke("erdo", "send_email", { to: "desk@example.com", subject: "New lead: Ana", plain_text: body, context: { kind: "lead_alert", lead_email: "ana@example.com" }, }); ``` It costs nothing to set and it is the difference between a log you can search and one you can only scroll. Set it whenever the recipient isn't the subject of the message. Mail sent before context existed, and mail from senders that don't set it, simply carries an empty context — treat it as optional on read, never as a guarantee. ## Access The log is scoped to the organization of the caller, enforced in the query itself. Requesting a message id belonging to another organization returns *not found* — identical to an id that doesn't exist, so the endpoint can't be used to probe for someone else's mail. ## Related The other direction — an address Erdo can *receive* at. Where lead-arrival alerts are usually sent from. Send outreach from your own domain instead of Erdo's. # Strategies Source: https://docs.erdo.ai/strategies Operate one business outcome through a root workstream and its campaign workstreams. # Strategies A **Strategy** is an operating role played by an existing [Workstream](/workstreams). It is not a separate Erdo resource. Use a long-lived root workstream for the business outcome and attach each campaign workstream directly beneath it with the existing `workstream` resource type and `child` relationship. Experiments remain hosted by the workstream doing the work. A campaign experiment therefore lives on its campaign workstream, while the root workstream supervises the portfolio. ## Attach campaign workstreams ```bash theme={null} erdo workstream attach brickell-lead-strategy \ --type workstream \ --id \ --rel child \ --title "Google Search · Brazilian buyer" ``` The same operation is available through `erdo_attach_workstream_resource` and `POST /v1/workstreams/:slug/resources`. ## Read the portfolio `erdo workstream ledger `, `erdo_read_workstream_ledger`, and `GET /v1/workstreams/:slug/ledger` return a one-level `child_portfolio`. Each entry contains the child workstream, experiment counts, and open-attention count. The read is deliberately one level deep and includes only direct children in the same organization and project. Deeper execution detail remains on each child workstream's own ledger. The project filter compares each child against the **root's** project, and attaching across projects is not an error. A child created in a different project attaches successfully, returns `200`, and is then absent from `child_portfolio` — so the portfolio reads as empty while the resource link plainly exists. Create campaign workstreams in the same project as their root, or read them through their own ledgers. ## Declare which provider campaigns a workstream governs A workstream that manages paid media must be able to say which provider campaigns it manages. That belongs in a typed field, not in an agent's instructions — an allowlist written in prose has to be edited by hand for every new campaign, in every record that repeats it, and nothing fails when two copies disagree. ```bash theme={null} curl -X PUT https://api.erdo.ai/v1/workstreams/brickell-search/external-refs \ -H "Authorization: Bearer $ERDO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"external_refs":[ {"provider":"google_ads","account_id":"8834039525", "kind":"campaign","external_id":"24033607833", "label":"2200 Brickell - Show the Offering"}]}' ``` Every field is an opaque provider string. Erdo stores what you resolved and hands it back; it does not parse the ids, check them against the provider, or infer scope of its own. You hold the evidence that produced the mapping. The write is a **replace**. Resolve the complete set each time: a campaign left out of the request leaves scope, which is how a campaign is unmanaged and how an explicit `[]` turns management off without deleting the workstream. There is no merge form, because under one a stale reference could never be removed. Reading needs no extra call. `external_refs` is on every workstream response and on every entry of a root ledger's `child_portfolio`, so a portfolio loop reads the whole account's scope from the ledger read it already makes. It is always present — `[]` when nothing is declared. The write is REST-only and has no MCP tool, so an agent can read the campaigns it may act on and cannot widen that set. Scope is declared by the system that owns the mapping. Declaring scope also decides where the resulting approvals live. A campaign lifecycle call — pausing a campaign, changing its daily budget — names the campaign and nothing else, so the approval it files is placed on the workstream that declared that campaign as its scope. It then appears under `erdo approvals list --workstream `, and `--scope always_this_workstream` on that approval grants standing authority for that action within that workstream only. A campaign no workstream declares still files its approval as normal; it simply belongs to no workstream. So does a campaign two workstreams both declare — where the answer is ambiguous, Erdo leaves the approval unplaced rather than guessing between them. ## Scope work and authority The shared Work feed and approval list accept an exact workstream slug: ```bash theme={null} erdo activity --workstream brickell-lead-strategy erdo approvals list --status pending --workstream brickell-lead-strategy erdo approvals decide --approve --scope always_this_workstream ``` MCP and REST use `workstream_slug` on `erdo_list_activity_feed`, `erdo_list_approvals`, `GET /v1/activity/feed`, and `GET /v1/approvals`. Exact scope is applied before pagination, so unrelated urgent or organization-level items cannot displace matching work. `always_this_workstream` creates a standing approval policy for that action and its parameter constraints on this workstream only. It does not grant authority to another workstream, and all provider writes continue through the existing approval system. # Client Reference Source: https://docs.erdo.ai/ts-sdk/client API reference for @erdoai/server # Client Reference The `@erdoai/server` package provides the `ErdoClient` class for invoking Erdo agents from server-side code. ## Installation ```bash theme={null} npm install @erdoai/server ``` ## ErdoClient ### Constructor ```typescript theme={null} import { ErdoClient } from '@erdoai/server'; // Server-side: Use authToken (API key) const serverClient = new ErdoClient({ endpoint?: string; // API endpoint (default: ERDO_ENDPOINT env or https://api.erdo.ai) authToken?: string; // API key (default: ERDO_AUTH_TOKEN env) }); // Client-side: Use scoped token (created via createToken) const clientClient = new ErdoClient({ endpoint: 'https://api.erdo.ai', token: scopedToken, // Scoped token from createToken() }); ``` You must provide either `authToken` or `token`. Use `authToken` for server-side code with full API access, and `token` for client-side code with limited scope. ### createToken() Create a scoped token for client-side use. Requires `authToken` (API key) authentication. ```typescript theme={null} const { token, tokenId, expiresAt } = await client.createToken(params: CreateTokenParams): Promise; ``` **CreateTokenParams:** ```typescript theme={null} interface CreateTokenParams { botKeys?: string[]; // Bot keys to grant access (e.g., ["my-org.data-analyst"]) datasetIds?: string[]; // Dataset IDs the token can access threadIds?: string[]; // Thread IDs the token can access (for pre-existing threads) externalUserId?: string; // Your user identifier (optional, for user reuse across tokens) expiresInSeconds?: number; // Token lifetime (default: 3600, max: 86400) } ``` **How External Users Work** Every scoped token is linked to an **external user** - a real user identity in Erdo's system. This enables proper RBAC and resource ownership. * **With `externalUserId`**: If you provide the same `externalUserId` across multiple tokens, they all authenticate as the same user. This is useful when your users need persistent access to their threads and resources. * **Without `externalUserId`**: A new user is created for each token. Use this for one-off or anonymous interactions where user persistence isn't needed. The `externalUserId` is your own user identifier (e.g., your database user ID). It's only used for matching - Erdo maintains its own internal user IDs. **TokenResponse:** ```typescript theme={null} interface TokenResponse { tokenId: string; // Token ID (for revocation) token: string; // The scoped token expiresAt: string; // ISO timestamp when token expires } ``` **Example:** ```typescript theme={null} // Server-side: Create a token for the frontend const serverClient = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); const { token, tokenId, expiresAt } = await serverClient.createToken({ botKeys: ['my-org.data-analyst'], // Bot keys externalUserId: 'user_123', // Your user's ID expiresInSeconds: 3600, }); // Pass token to frontend for client-side use ``` ### invoke() Invoke an agent and wait for the complete result. **Server-only**: This method uses `/bots/{key}/invoke` which returns raw SSE events without message wrapping. For React UI rendering with the `Content` component, use thread-based messaging (`sendMessage()` or the `useThread` hook). ```typescript theme={null} const result = await client.invoke(botKey: string, params: InvokeParams): Promise; ``` **Parameters:** | Parameter | Type | Description | | --------- | -------------- | --------------------------------------------- | | `botKey` | `string` | The agent identifier (e.g., `'data-analyst'`) | | `params` | `InvokeParams` | Invocation parameters | **InvokeParams:** ```typescript theme={null} interface InvokeParams { messages?: Message[]; // Messages to send to the agent parameters?: Record; // Additional parameters datasets?: string[]; // Dataset slugs to include mode?: InvocationMode; // 'live' | 'replay' | 'manual' } interface Message { role: 'user' | 'assistant' | 'system'; content: string; } ``` **InvokeResult:** ```typescript theme={null} interface InvokeResult { success: boolean; botId?: string; invocationId?: string; result?: { status?: string; output?: { content?: ContentItem[]; }; }; messages: MessageContent[]; events: SSEEvent[]; steps: StepInfo[]; error?: string; } ``` **Example:** ```typescript theme={null} const result = await client.invoke('data-analyst', { messages: [ { role: 'user', content: 'What were our top 10 products by revenue?' } ], datasets: ['sales-data'], }); if (result.success) { console.log('Status:', result.result?.status); console.log('Content:', result.result?.output?.content); } ``` ### invokeStream() Invoke an agent and stream results as they arrive. **Server-only**: This method uses `/bots/{key}/invoke` which returns raw SSE events without message wrapping. For React UI rendering with the `Content` component, use thread-based messaging (`sendMessage()` or the `useThread` hook). ```typescript theme={null} const stream = client.invokeStream(botKey: string, params: InvokeParams): AsyncGenerator; ``` **SSEEvent:** ```typescript theme={null} interface SSEEvent { type?: 'content' | 'status' | 'error' | 'done' | string; payload?: any; metadata?: { user_visibility?: 'visible' | 'hidden'; content_type?: string; ui_content_type?: string; }; } ``` **Example:** ```typescript theme={null} const events: SSEEvent[] = []; for await (const event of client.invokeStream('data-analyst', { messages: [{ role: 'user', content: 'Analyze trends in our data' }], })) { events.push(event); switch (event.type) { case 'content': // New content item (chart, text, etc.) console.log('Content:', event.payload); break; case 'status': // Status update (step started, completed, etc.) console.log('Status:', event.payload); break; case 'error': console.error('Error:', event.payload); break; case 'done': console.log('Stream complete'); break; } } ``` ## Thread Methods Thread methods work with any scoped token. Each token is linked to an external user, and threads created are owned by that user. For persistent user threads (where users can return to their conversations), use `externalUserId` when creating tokens. This ensures the same user identity across sessions. ### createThread() Create a new thread for the authenticated user. ```typescript theme={null} const thread = await client.createThread(params?: CreateThreadParams): Promise; ``` **CreateThreadParams:** ```typescript theme={null} interface CreateThreadParams { name?: string; // Optional thread name datasetIds?: string[]; // Dataset IDs to associate with the thread } ``` **Thread:** ```typescript theme={null} interface Thread { id: string; name: string; createdAt: string; updatedAt: string; } ``` **Example:** ```typescript theme={null} const client = new ErdoClient({ endpoint: 'https://api.erdo.ai', token: scopedToken, // Token with externalUserId }); const thread = await client.createThread({ name: 'Support Chat' }); console.log('Created thread:', thread.id); ``` ### listThreads() List threads. The behavior depends on your authentication method: ```typescript theme={null} const { threads } = await client.listThreads(params?: ListThreadsParams): Promise; ``` **ListThreadsParams:** ```typescript theme={null} interface ListThreadsParams { externalUserId?: string; // Filter by external user (API key only) } ``` When using a scoped token, returns threads owned by the token's user. The `externalUserId` parameter is ignored (the token already identifies the user). ```typescript theme={null} // Client authenticated with scoped token const client = new ErdoClient({ token: scopedToken }); // Returns only this user's threads const { threads } = await client.listThreads(); ``` When using an API key, you can either list your own threads or filter by a specific external user. ```typescript theme={null} // Client authenticated with API key const client = new ErdoClient({ authToken: apiKey }); // List your own threads const { threads } = await client.listThreads(); // Filter to a specific external user's threads const { threads } = await client.listThreads({ externalUserId: 'user_123' }); ``` **Security: Never accept `externalUserId` from client requests** When using the proxy pattern with API key auth, you must get `externalUserId` from your own authentication system (session, JWT, etc.). Never trust client-provided user IDs—this would allow users to access other users' threads. ```typescript theme={null} // CORRECT: Get user from YOUR auth system const session = await getServerSession(authOptions); const { threads } = await client.listThreads({ externalUserId: session.user.id // From your auth, NOT from request }); // WRONG: Never do this! const { externalUserId } = await request.json(); // Attacker-controlled! const { threads } = await client.listThreads({ externalUserId }); ``` ### getThread() Get a specific thread by ID. ```typescript theme={null} const thread = await client.getThread(threadId: string): Promise; ``` **Example:** ```typescript theme={null} const thread = await client.getThread('thread_abc123'); console.log(thread.name, thread.updatedAt); ``` ### getThreadMessages() Get all messages in a thread. Useful for loading conversation history when a user returns to a previous thread. ```typescript theme={null} const { messages } = await client.getThreadMessages(threadId: string): Promise; ``` **ListThreadMessagesResponse:** ```typescript theme={null} interface ListThreadMessagesResponse { messages: ThreadMessage[]; } interface ThreadMessage { id: string; role: 'user' | 'assistant'; contents: ContentItem[]; createdAt: string; updatedAt: string; } ``` **Example:** ```typescript theme={null} // Load conversation history for a thread const { messages } = await client.getThreadMessages(threadId); for (const message of messages) { console.log(`${message.role}: ${message.contents.length} content items`); // Render content items (charts, tables, text, etc.) for (const content of message.contents) { console.log(` - ${content.content_type}`); } } ``` Use `getThreadMessages()` to build a threads sidebar where users can click on previous conversations and see the full message history with all visualizations. ### sendMessage() Send a message to a thread and stream the bot response. ```typescript theme={null} const stream = client.sendMessage(threadId: string, params: SendMessageParams): AsyncGenerator; ``` **SendMessageParams:** ```typescript theme={null} interface SendMessageParams { content: string; // The message content botKey?: string; // Optional bot to use (uses thread's default if not specified) } ``` **Example:** ```typescript theme={null} for await (const event of client.sendMessage(threadId, { content: 'What insights can you find in my data?', botKey: 'my-org.data-analyst', })) { switch (event.type) { case 'content': console.log('Content:', event.payload); break; case 'status': console.log('Status:', event.payload); break; case 'done': console.log('Stream complete'); break; } } ``` ### sendMessageAndWait() Send a message and wait for the complete response (non-streaming). ```typescript theme={null} const events = await client.sendMessageAndWait(threadId: string, params: SendMessageParams): Promise; ``` **Example:** ```typescript theme={null} const events = await client.sendMessageAndWait(threadId, { content: 'Summarize recent trends', }); console.log('Received', events.length, 'events'); ``` ## Content Types Agents can return various content types: | Type | Description | | ---------- | ----------------------------- | | `text` | Plain text response | | `json` | Structured JSON data | | `markdown` | Formatted markdown text | | `chart` | Chart configuration with data | | `table` | Tabular data | | `code` | Code snippets | **ContentItem:** ```typescript theme={null} interface ContentItem { content_type: 'text' | 'json' | 'code' | 'table' | 'image' | 'error'; ui_content_type?: 'chart' | 'bar_chart' | 'line_chart' | 'pie_chart' | 'table' | 'markdown'; content?: string; data?: any; } ``` ## Error Handling ```typescript theme={null} try { const result = await client.invoke('data-analyst', { messages: [{ role: 'user', content: 'Analyze data' }], }); if (!result.success) { console.error('Invocation failed:', result.error); } } catch (error) { // Network errors, auth errors, etc. console.error('Request failed:', error); } ``` ## Node.js Example ```typescript theme={null} import { ErdoClient } from '@erdoai/server'; async function main() { const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); console.log('Invoking agent...'); for await (const event of client.invokeStream('data-analyst', { messages: [{ role: 'user', content: 'What insights can you find?' }], })) { if (event.type === 'content') { console.log('Received:', event.payload?.content_type); } } } main(); ``` ## B2B Integration Example For B2B applications where your customers' users need to interact with Erdo agents, use scoped tokens to provide secure, limited access. ### Server-side: Create Token for User ```typescript theme={null} // api/authorize/route.ts (Next.js API route) import { ErdoClient } from '@erdoai/server'; export async function POST(request: Request) { const { userId, botId } = await request.json(); // Your own authorization logic here // e.g., check if userId belongs to authenticated session const serverClient = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); const { token, tokenId, expiresAt } = await serverClient.createToken({ botKeys: [botKey], // Bot keys externalUserId: userId, // Your user ID - enables persistent threads expiresInSeconds: 3600, }); return Response.json({ token, tokenId, expiresAt }); } ``` **Why use `externalUserId`?** When you pass your user's ID as `externalUserId`, Erdo creates a persistent user identity. This means: * The same user can have multiple tokens over time (e.g., after token expiry) * All tokens with the same `externalUserId` see the same threads and resources * Perfect for apps where users need to return to their conversation history If you omit `externalUserId`, each token creates a new isolated user - useful for anonymous or one-time interactions. ### Client-side: Use Token for Threads ```typescript theme={null} // Get token from your server const { token } = await fetch('/api/authorize', { method: 'POST', body: JSON.stringify({ userId: currentUser.id }), }).then(r => r.json()); // Create client with scoped token const client = new ErdoClient({ endpoint: 'https://api.erdo.ai', token, }); // Create a thread for the user const thread = await client.createThread({ name: 'Data Analysis' }); // Send messages and stream responses for await (const event of client.sendMessage(thread.id, { content: 'What were our top products last quarter?', botKey: 'my-org.data-analyst', })) { if (event.type === 'content') { // Render content to UI console.log(event.payload); } } // Later: List user's threads const { threads } = await client.listThreads(); ``` # Integration Patterns Source: https://docs.erdo.ai/ts-sdk/integration Common patterns for integrating Erdo into your applications # Integration Patterns This guide covers common patterns for integrating Erdo into different application architectures. ## Next.js App Router ### Server Component Fetch data on the server: ```tsx theme={null} // app/analysis/page.tsx import { ErdoClient } from '@erdoai/server'; export default async function AnalysisPage() { const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); const result = await client.invoke('data-analyst', { messages: [{ role: 'user', content: 'Generate a summary report' }], }); return (
{result.result?.output?.content?.map((item, i) => (
{JSON.stringify(item)}
))}
); } ``` ### Client Component with Streaming Never expose your API key to the browser. Choose one of two secure streaming patterns below. There are two ways to stream Erdo results to your frontend: | Pattern | How it works | Best for | | -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- | | **Ephemeral Tokens** | Backend creates a short-lived token, frontend streams directly from Erdo API | Lower latency, simpler backend | | **Proxy Streaming** | Backend proxies the SSE stream | Strict CSP requirements, custom logging/rate limiting | #### Pattern 1: Ephemeral Tokens (Recommended) Your backend creates a scoped, short-lived token. The frontend uses it to invoke directly. ```tsx theme={null} // app/api/authorize/route.ts (SERVER) import { ErdoClient } from '@erdoai/server'; const ERDO_ENDPOINT = process.env.ERDO_ENDPOINT || 'https://api.erdo.ai'; const client = new ErdoClient({ endpoint: ERDO_ENDPOINT, authToken: process.env.ERDO_AUTH_TOKEN }); export async function POST(request: Request) { const { botKey } = await request.json(); // Add your own RBAC logic here // if (!user.canAccess(botKey)) return Response.json({ error: 'Forbidden' }, { status: 403 }); const { token, tokenId, expiresAt } = await client.createToken({ botKeys: [botKey], // Bot keys (e.g., "my-org.data-analyst") expiresInSeconds: 3600, // 1 hour }); // The frontend reads `endpoint` from this response to point ErdoClient at the API. return Response.json({ token, tokenId, expiresAt, endpoint: ERDO_ENDPOINT }); } ``` ```tsx theme={null} // app/chat/page.tsx (CLIENT) 'use client'; import { useState, useRef, useCallback, useMemo } from 'react'; import { ErdoClient } from '@erdoai/server'; import { ErdoProvider, useThread, Content } from '@erdoai/ui'; function ChatInterface() { const [query, setQuery] = useState(''); const { activeMessages, isStreaming, sendMessage } = useThread({ botKey: 'data-analyst', }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await sendMessage(query); setQuery(''); }; const contents = activeMessages.flatMap(msg => msg.contents || []); return (
setQuery(e.target.value)} />
{contents.map((item, i) => )}
); } export default function ChatPage() { const [client, setClient] = useState(null); const tokenRef = useRef<{ token: string; expiresAt: Date; endpoint: string } | null>(null); const authenticate = useCallback(async () => { if (tokenRef.current && new Date(tokenRef.current.expiresAt) > new Date()) { return; } const res = await fetch('/api/authorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ botKey: 'my-org.data-analyst' }), // Bot key }); const { token, expiresAt, endpoint } = await res.json(); tokenRef.current = { token, expiresAt: new Date(expiresAt), endpoint }; setClient(new ErdoClient({ endpoint, token })); }, []); const config = useMemo(() => ({ baseUrl: tokenRef.current?.endpoint || '', client: client || undefined, }), [client]); if (!client) { return ; } return ( ); } ``` #### Pattern 2: Proxy Streaming Your backend proxies all requests. The API key never leaves your server. ```tsx theme={null} // app/api/threads/route.ts (SERVER - create thread) import { ErdoClient } from '@erdoai/server'; const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN }); export async function POST() { const thread = await client.createThread(); return Response.json(thread); } ``` ```tsx theme={null} // app/api/threads/[threadId]/message/route.ts (SERVER - send message) import { ErdoClient } from '@erdoai/server'; const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN }); export async function POST( request: Request, { params }: { params: Promise<{ threadId: string }> } ) { const { threadId } = await params; const { content, botKey } = await request.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { try { for await (const event of client.sendMessage(threadId, { content, botKey })) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); } controller.enqueue(encoder.encode('data: [DONE]\n\n')); } finally { controller.close(); } }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', }, }); } ``` ```tsx theme={null} // app/chat/page.tsx (CLIENT) 'use client'; import { useState, useCallback, useRef } from 'react'; import { ErdoProvider, Content, handleSSEEvent } from '@erdoai/ui'; import type { SSEEvent } from '@erdoai/types'; function ChatInterface() { const [query, setQuery] = useState(''); const [threadId, setThreadId] = useState(null); const [activeMessages, setActiveMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsStreaming(true); // Create thread if needed let currentThreadId = threadId; if (!currentThreadId) { const res = await fetch('/api/threads', { method: 'POST' }); const thread = await res.json(); currentThreadId = thread.id; setThreadId(thread.id); } // Send message via proxy const response = await fetch(`/api/threads/${currentThreadId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: query, botKey: 'data-analyst' }), }); // Process SSE stream const reader = response.body?.getReader(); const decoder = new TextDecoder(); const messagesByID: Record = {}; while (reader) { const { done, value } = await reader.read(); if (done) break; const lines = decoder.decode(value).split('\n'); for (const line of lines) { if (line.startsWith('data: ') && line.slice(6) !== '[DONE]') { const event: SSEEvent = JSON.parse(line.slice(6)); handleSSEEvent(event.type || '', event, [], currentThreadId, messagesByID); setActiveMessages(Object.values(messagesByID)); } } } setIsStreaming(false); }; const contents = activeMessages.flatMap(msg => msg.contents || []); return (
setQuery(e.target.value)} />
{contents.map((item, i) => )}
); } export default function ChatPage() { return ( ); } ``` ## Vercel AI SDK For integrating Erdo with Vercel AI SDK, see the dedicated [Vercel AI SDK guide](/ts-sdk/vercel-ai-sdk). It covers: * Connecting Erdo's MCP tools to any LLM via `@ai-sdk/mcp` * Rendering rich charts and tables with `ErdoToolResult` * The bridge pattern using `client.getTools()` ## Express.js / Node.js ### REST API Endpoint ```typescript theme={null} // server.ts import express from 'express'; import { ErdoClient } from '@erdoai/server'; const app = express(); const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); app.use(express.json()); app.post('/api/analyze', async (req, res) => { const { query } = req.body; try { const result = await client.invoke('data-analyst', { messages: [{ role: 'user', content: query }], }); res.json(result); } catch (error) { res.status(500).json({ error: 'Analysis failed' }); } }); app.listen(3000); ``` ### SSE Streaming Endpoint ```typescript theme={null} app.get('/api/analyze/stream', async (req, res) => { const query = req.query.q as string; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); try { for await (const event of client.invokeStream('data-analyst', { messages: [{ role: 'user', content: query }], })) { res.write(`data: ${JSON.stringify(event)}\n\n`); } } catch (error) { res.write(`data: ${JSON.stringify({ type: 'error', payload: error })}\n\n`); } res.end(); }); ``` ## Proxying Through Your Backend For B2B applications, you may want to proxy Erdo API requests through your own backend rather than having the client call `api.erdo.ai` directly. This approach: * Keeps your Erdo API key server-side only * Avoids CSP configuration for `api.erdo.ai` * Allows you to add custom authentication, logging, or rate limiting ### Option 1: Custom Endpoint Point the SDK to your own API endpoint: ```typescript theme={null} // Server-side client const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, endpoint: 'https://your-backend.com/api/erdo', // Your proxy }); // Or via environment variable // ERDO_ENDPOINT=https://your-backend.com/api/erdo ``` ```tsx theme={null} // UI provider {children} ``` Your backend then forwards requests to `api.erdo.ai`: ```typescript theme={null} // Your backend proxy endpoint app.all('/api/erdo/*', async (req, res) => { const erdoPath = req.path.replace('/api/erdo', ''); const response = await fetch(`https://api.erdo.ai${erdoPath}`, { method: req.method, headers: { 'Authorization': `Bearer ${process.env.ERDO_AUTH_TOKEN}`, 'Content-Type': 'application/json', }, body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined, }); // Forward the response (including SSE streams) res.status(response.status); response.body?.pipeTo(new WritableStream({ write: (chunk) => res.write(chunk), close: () => res.end(), })); }); ``` ### Option 2: Custom Data Fetcher For more control over how data is fetched (e.g., using a typed API client), provide a custom `dataFetcher`: ```tsx theme={null} // lib/erdo-fetcher.ts - Create ONCE outside components import { DataFetcher } from '@erdoai/ui'; export const erdoDataFetcher: DataFetcher = { fetchDatasetContents: async (slug, invocationId) => { // Use your own API client const res = await fetch(`/api/datasets/${slug}?invocationId=${invocationId}`); return res.json(); }, }; ``` ```tsx theme={null} // providers/erdo-provider.tsx import { ErdoProvider } from '@erdoai/ui'; import { erdoDataFetcher } from '../lib/erdo-fetcher'; export function AppProvider({ children }) { return ( {children} ); } ``` When using a custom `dataFetcher`, the `baseUrl` is still used by the `useThread` hook. Only `fetchDatasetContents` calls are overridden. ## Error Handling ### Client-Side Error Boundary ```tsx theme={null} import { ErrorBoundary } from '@erdoai/ui'; function App() { return ( Something went wrong}> ); } ``` ### Hook-Level Error Handling ```tsx theme={null} const { error, sendMessage } = useThread({ botKey: 'data-analyst', onError: (err) => { // Log to error tracking service console.error('Message failed:', err); toast.error('Analysis failed. Please try again.'); }, }); ``` ## Authentication ### API Key (Server-Side) Store your API key securely in environment variables: ```bash theme={null} # .env ERDO_AUTH_TOKEN=your-api-key ``` ```typescript theme={null} // Only use on server-side const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); ``` ### Client-Side Authentication Never expose API keys to the browser. Use **ephemeral tokens** or **proxy streaming** instead. For client-side usage, you have two secure options: 1. **Ephemeral Tokens**: Your backend creates a short-lived, scoped token using `createToken()`. The frontend uses this token to invoke directly. See [Client Component with Streaming](#client-component-with-streaming) above. 2. **Proxy Streaming**: Your backend proxies all requests to the Erdo API. The API key never leaves your server. See [Proxying Through Your Backend](#proxying-through-your-backend) below. ### Scoped Tokens (B2B2C) For B2B2C applications where your customers need to expose Erdo agents and datasets to their end users, use scoped tokens. Scoped tokens are: * **Short-lived**: Expire after 1-24 hours (configurable) * **Scoped**: Only grant access to specific bots and datasets * **User-bound**: Can be linked to your external user ID for thread management This pattern is ideal for: * SaaS products embedding AI agents for their customers * Dashboards where end users should only access specific bots/datasets * Applications requiring fine-grained, temporary access control #### Creating Scoped Tokens (Backend) Create a scoped token from your backend using `createToken()`: ```typescript theme={null} // Your backend API route import { ErdoClient } from '@erdoai/server'; const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); app.post('/api/erdo-token', async (req, res) => { // Create a scoped token for this user const { token, tokenId, expiresAt } = await client.createToken({ botKeys: ['my-org.data-analyst'], // Bot keys the user can access datasetIds: ['dataset-uuid-1'], // Datasets the user can query externalUserId: req.user.id, // Links token to your user for thread access expiresInSeconds: 3600, // 1 hour }); // Return the token to your frontend res.json({ token, tokenId, expiresAt }); }); ``` **Thread access options:** * **`externalUserId`** (recommended): Threads created by the token holder are automatically accessible. The user can list, view, and continue their own threads across sessions. * **`threadIds`**: Grant access to specific pre-existing threads. Useful when you want to give a user access to threads they didn't create (e.g., shared conversations). ```typescript theme={null} // Grant access to specific threads const { token, tokenId } = await client.createToken({ botKeys: ['my-org.data-analyst'], // Bot keys threadIds: ['thread-uuid-1', 'thread-uuid-2'], // Pre-existing threads expiresInSeconds: 3600, }); ``` #### Using Scoped Tokens (Frontend) Pass the scoped token to `ErdoProvider`: ```tsx theme={null} 'use client'; import { ErdoProvider, DatasetChart } from '@erdoai/ui'; import { useEffect, useState } from 'react'; function Dashboard() { const [token, setToken] = useState(null); useEffect(() => { // Fetch scoped token from your backend fetch('/api/erdo-token', { method: 'POST' }) .then(res => res.json()) .then(data => setToken(data.token)); }, []); if (!token) return
Loading...
; return ( ); } ``` #### Using Threads with Scoped Tokens When a token is created with `externalUserId`, users can create and manage persistent conversation threads: ```typescript theme={null} import { ErdoClient } from '@erdoai/server'; // Client-side: Use the scoped token const client = new ErdoClient({ endpoint: 'https://api.erdo.ai', token: scopedToken, }); // Create a thread for this user const thread = await client.createThread({ name: 'Data Analysis' }); // Send messages and stream responses for await (const event of client.sendMessage(thread.id, { content: 'What were our top products last quarter?', botKey: 'my-org.data-analyst', })) { console.log(event.type, event.payload); } // List user's threads const { threads } = await client.listThreads(); ``` #### Persisting External User IDs To enable users to access their threads across sessions, store the external user ID in your database: ```typescript theme={null} // Your database schema // users: { id, email, erdo_external_user_id } // When user signs up or first uses Erdo async function getOrCreateErdoUserId(userId: string) { const user = await db.users.find(userId); if (!user.erdo_external_user_id) { // Generate and store a unique ID for this user user.erdo_external_user_id = `ext-${userId}`; // Or use crypto.randomUUID() await db.users.update(userId, { erdo_external_user_id: user.erdo_external_user_id }); } return user.erdo_external_user_id; } // When creating tokens, use the stored ID app.post('/api/erdo-token', async (req, res) => { const externalUserId = await getOrCreateErdoUserId(req.user.id); const { token } = await client.createToken({ botKeys: ['my-org.data-analyst'], externalUserId, // Same ID every time = same threads expiresInSeconds: 3600, }); res.json({ token }); }); ``` The external user ID is embedded in the token and never exposed to the client. Users cannot access other users' threads. #### Persisting Message History For production applications, we recommend storing messages in your own database rather than fetching from Erdo each time. This gives you full control and avoids extra API calls: ```tsx theme={null} // Save messages when streaming completes const { streamingContents, sendMessage } = useThread({ botKey: 'my-org.data-analyst', onFinish: async () => { // Save to your database await db.messages.create({ threadId, role: 'assistant', contents: streamingContents, createdAt: new Date(), }); }, }); // Load history from your database const { data: history } = useQuery({ queryKey: ['messages', threadId], queryFn: () => db.messages.findByThreadId(threadId), }); ``` Alternatively, you can fetch history from Erdo using `client.getThreadMessages()`: ```typescript theme={null} // Fetch from Erdo API (simpler, but adds latency) const { messages } = await client.getThreadMessages(threadId); ``` #### Building a Threads Sidebar A common pattern is to show users their previous conversations in a sidebar. Here's how to implement this: ```tsx theme={null} function ThreadsSidebar({ client, selectedThreadId, onSelectThread, }: { client: ErdoClient; selectedThreadId: string | null; onSelectThread: (threadId: string, messages: ThreadMessage[]) => void; }) { const [threads, setThreads] = useState([]); const [isLoading, setIsLoading] = useState(true); // Fetch threads on mount useEffect(() => { async function fetchThreads() { const { threads } = await client.listThreads(); setThreads(threads); setIsLoading(false); } fetchThreads(); }, [client]); // Load messages when a thread is selected const handleSelect = async (thread: Thread) => { const { messages } = await client.getThreadMessages(thread.id); onSelectThread(thread.id, messages); }; if (isLoading) return
Loading...
; return (
{threads.map((thread) => ( ))}
); } ``` Then use it with your chat component: ```tsx theme={null} function ChatApp() { const [selectedThreadId, setSelectedThreadId] = useState(null); const [initialMessages, setInitialMessages] = useState([]); const handleSelectThread = (threadId: string | null, messages: ThreadMessage[]) => { setSelectedThreadId(threadId); setInitialMessages(messages); }; return (
); } ``` #### Token API Reference **CreateTokenParams:** ```typescript theme={null} interface CreateTokenParams { botKeys?: string[]; // Bot keys to grant access (e.g., ["my-org.data-analyst"]) datasetIds?: string[]; // Dataset IDs the token can query threadIds?: string[]; // Thread IDs the token can access externalUserId?: string; // Your user ID (enables thread management) expiresInSeconds?: number; // Token lifetime (default: 3600) } ``` **TokenResponse:** ```typescript theme={null} interface TokenResponse { tokenId: string; // Token ID (for revocation) token: string; // The scoped token expiresAt: string; // ISO timestamp when token expires } ``` Scoped tokens are more secure than API keys for client-side use because they: * Expire automatically * Only grant access to specific bots and datasets * Can be linked to external users for personalized thread access ## Content Security Policy (CSP) If your application uses Content Security Policy headers, you'll need to allow connections to the Erdo API for the UI components to fetch data. ### Required Directives Add the following to your CSP configuration: ``` connect-src 'self' https://api.erdo.ai; ``` This allows the `@erdoai/ui` components to: * Fetch dataset contents for rendering charts and tables * Stream agent invocation results in real-time ### Next.js Configuration ```javascript theme={null} // next.config.js const securityHeaders = [ { key: 'Content-Security-Policy', value: ` default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.erdo.ai; img-src 'self' data: blob:; `.replace(/\n/g, ''), }, ]; module.exports = { async headers() { return [ { source: '/(.*)', headers: securityHeaders, }, ]; }, }; ``` ### Nginx Configuration ```nginx theme={null} add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.erdo.ai; script-src 'self'; style-src 'self' 'unsafe-inline';" always; ``` ### Meta Tag (Fallback) If you can't configure server headers, use a meta tag: ```html theme={null} ``` If you're [proxying Erdo API requests through your own backend](#proxying-through-your-backend), you don't need to add `api.erdo.ai` to your CSP—just ensure your proxy endpoint is allowed. # TypeScript SDK Overview Source: https://docs.erdo.ai/ts-sdk/overview Integrate Erdo AI agents into your TypeScript/JavaScript applications # TypeScript SDK Overview The Erdo TypeScript SDK enables you to integrate Erdo's AI agents directly into your applications. Whether you're building a Next.js app, a Node.js backend, or a React frontend, the SDK provides everything you need to invoke agents and render their results. ## Packages | Package | Description | | ---------------- | -------------------------------------------- | | `@erdoai/server` | Server-side client for invoking Erdo agents | | `@erdoai/ui` | React components for rendering agent results | ## Installation ```bash npm theme={null} npm install @erdoai/server @erdoai/ui ``` ```bash yarn theme={null} yarn add @erdoai/server @erdoai/ui ``` ```bash pnpm theme={null} pnpm add @erdoai/server @erdoai/ui ``` ## Quick Start ### 1. Create an API Token Get your API token from the [Erdo dashboard](https://erdo.ai/settings/api-token). ### 2. Invoke an Agent ```typescript theme={null} import { ErdoClient } from '@erdoai/server'; const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); // Invoke an agent and get the result const result = await client.invoke('data-analyst', { messages: [{ role: 'user', content: 'What were our top products last month?' }], }); console.log(result.messages); ``` ### 3. Stream Results For real-time updates, use streaming: ```typescript theme={null} for await (const event of client.invokeStream('data-analyst', { messages: [{ role: 'user', content: 'Analyze our sales data' }], })) { switch (event.type) { case 'content': console.log('New content:', event.payload); break; case 'status': console.log('Status:', event.payload); break; case 'done': console.log('Complete!'); break; } } ``` ### 4. Render Results in React Never expose your API key to the browser. Use one of two secure patterns: **ephemeral tokens** or **proxy streaming**. **Option A: Ephemeral Tokens (Recommended)** Your backend creates a short-lived, scoped token. The frontend uses it to stream directly from the Erdo API. ```tsx theme={null} // app/api/authorize/route.ts (SERVER) import { ErdoClient } from '@erdoai/server'; const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN }); export async function POST(request: Request) { const { botKey } = await request.json(); // Add your own RBAC logic here const { token, tokenId, expiresAt } = await client.createToken({ botKeys: [botKey], // Bot keys (e.g., "my-org.data-analyst") expiresInSeconds: 3600, }); return Response.json({ token, tokenId, expiresAt }); } ``` ```tsx theme={null} // app/page.tsx (CLIENT) 'use client'; import { Content } from '@erdoai/ui'; function ChatInterface() { const [contents, setContents] = useState([]); const handleSubmit = async (query: string) => { // 1. Get ephemeral token from your backend const { token } = await fetch('/api/authorize', { method: 'POST', body: JSON.stringify({ botKey: 'data-analyst' }), }).then(r => r.json()); // 2. Stream directly from Erdo API const response = await fetch('https://api.erdo.ai/bots/data-analyst/invoke', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', content: query }] }), }); // 3. Parse SSE stream and render results // See integration docs for full streaming example }; return (
{contents.map((item, i) => )}
); } ``` **Option B: Proxy Streaming** Your backend proxies the stream. The API key never leaves your server. ```tsx theme={null} // app/api/invoke/route.ts (SERVER) import { ErdoClient } from '@erdoai/server'; const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN }); export async function POST(request: Request) { const { botKey, messages } = await request.json(); const stream = new ReadableStream({ async start(controller) { for await (const event of client.invokeStream(botKey, { messages })) { controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)); } controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' }, }); } ``` See [Integration Patterns](/ts-sdk/integration) for complete examples of both approaches. ## Use Cases ### Embed Data Analysis Add AI-powered data analysis to your existing application: ```typescript theme={null} // User asks a question in your app const result = await client.invoke('data-analyst', { messages: [{ role: 'user', content: userQuestion }], datasets: ['sales-data', 'customer-data'], }); // Render charts and insights ``` ### AI Tool in Chat Applications Use Erdo as a tool alongside your LLM for data-intensive queries: ```typescript theme={null} // When your LLM detects a data question, delegate to Erdo if (isDataQuestion(userMessage)) { const erdoResult = await client.invoke('data-analyst', { messages: [{ role: 'user', content: userMessage }], }); // Return rich visualizations to the user } ``` ### Automated Reporting Generate reports programmatically: ```typescript theme={null} const report = await client.invoke('data-analyst', { messages: [{ role: 'user', content: 'Generate a weekly sales report' }], parameters: { dateRange: 'last_7_days', format: 'detailed', }, }); ``` ## Environment Variables Configure the SDK using environment variables: | Variable | Description | Default | | ----------------- | ----------------- | --------------------- | | `ERDO_AUTH_TOKEN` | Your Erdo API key | Required | | `ERDO_ENDPOINT` | API endpoint | `https://api.erdo.ai` | ```typescript theme={null} // Uses environment variables automatically const client = new ErdoClient(); ``` ## Next Steps * [Client Reference](/ts-sdk/client) - Detailed API documentation * [UI Components](/ts-sdk/ui-components) - React component reference * [Integration Patterns](/ts-sdk/integration) - Vercel AI SDK, Next.js patterns # UI Components Source: https://docs.erdo.ai/ts-sdk/ui-components React components for rendering Erdo agent results # UI Components The `@erdoai/ui` package provides React components for rendering agent results, including charts, tables, and formatted content. ## Installation ```bash theme={null} npm install @erdoai/ui ``` **Peer Dependencies:** ```bash theme={null} npm install react react-dom ``` ## ErdoProvider Wrap your app with `ErdoProvider` to configure the SDK: ```tsx theme={null} import { ErdoProvider } from '@erdoai/ui'; function App() { return ( {children} ); } ``` Never expose API keys to the browser. For streaming invocations, use ephemeral tokens or proxy streaming. See [Integration Patterns](/ts-sdk/integration#client-component-with-streaming) for secure patterns. **Config Options:** | Option | Type | Description | | ------------------ | --------------------- | -------------------------------------------------------------------------------- | | `baseUrl` | `string` | API base URL (or your proxy URL) | | `token` | `string` | Ephemeral token from `createToken()` for authenticated data fetching | | `dataFetcher` | `DataFetcher` | Optional custom data fetcher (see [Custom Data Fetching](#custom-data-fetching)) | | `ContentComponent` | `React.ComponentType` | Optional component for rendering nested content | | `threadId` | `string` | Thread ID for dataset operations | ### ContentComponent The `ContentComponent` config option allows you to provide a default component for rendering nested content in invocation components (like `InvocationEvents`, `Output`, `StepInvocation`): ```tsx theme={null} import { ErdoProvider, Content } from '@erdoai/ui'; {children} ``` This is useful when: * You want to customize how content is rendered across all invocation components * You have your own Content component with custom renderers * You want to avoid passing `ContentComponent` as a prop to every component Components will automatically use the provider's `ContentComponent` as a default, but you can still override it via props if needed. ### Custom Data Fetching The SDK uses plain React state for data fetching (no React Query required). You can provide a custom `DataFetcher` to: * **Proxy requests** through your own backend (keeping API keys server-side) * **Add custom authentication** headers or logic * **Transform data** before it reaches components * **Use your own API** instead of Erdo's REST endpoints ```tsx theme={null} import { ErdoProvider, type DataFetcher } from '@erdoai/ui'; const customFetcher: DataFetcher = { // Required: fetch dataset contents for charts/tables fetchDatasetContents: async (slug, invocationId) => { const response = await fetch(`/api/datasets/${slug}?invocationId=${invocationId}`); return response.json(); }, // Optional: get dataset details for download buttons getDatasetDetails: async (slug, threadId) => { const response = await fetch(`/api/datasets/${slug}/details?threadId=${threadId}`); if (!response.ok) return null; return response.json(); // { id: string, name: string } }, // Optional: download dataset as file downloadDataset: async (datasetId) => { const response = await fetch(`/api/datasets/${datasetId}/download`); return response.blob(); }, }; function App() { return ( {children} ); } ``` **DataFetcher Interface:** | Method | Required | Description | | ------------------------------------------ | -------- | -------------------------------------------- | | `fetchDatasetContents(slug, invocationId)` | Yes | Fetch dataset rows for charts/tables | | `getDatasetDetails(slug, threadId)` | No | Get dataset ID and name for download buttons | | `downloadDataset(datasetId)` | No | Download dataset file as Blob | If no `dataFetcher` is provided, the SDK falls back to the REST API using `baseUrl` and `token`/`authToken` for authentication. ## Hooks ### useThread Send messages to threads with streaming support: ```tsx theme={null} import { useThread, Content } from '@erdoai/ui'; function ChatInterface() { const { isStreaming, streamingContents, error, sendMessage } = useThread({ botKey: 'data-analyst', onFinish: () => console.log('Done'), onError: (error) => console.error('Error:', error), }); return (
{error &&
{error.message}
} {streamingContents.map((content) => ( ))}
); } ``` **Options:** | Option | Type | Description | | ---------- | ------------------------ | ----------------------------------------------- | | `botKey` | `string` | Bot key for the thread (e.g., `'data-analyst'`) | | `threadId` | `string` | Optional existing thread ID to resume | | `onFinish` | `() => void` | Called when message streaming completes | | `onError` | `(error: Error) => void` | Called on error | **Returns:** | Property | Type | Description | | ------------------- | ------------------------------------ | ----------------------------------------- | | `streamingContents` | `ContentItem[]` | Visible content items ready for rendering | | `isStreaming` | `boolean` | Whether currently streaming | | `error` | `Error \| null` | Most recent error | | `sendMessage` | `(content: string) => Promise` | Send a message | | `thread` | `Thread \| null` | Current thread object | | `activeMessages` | `MessageWithContents[]` | Raw messages (for advanced use) | | `setThread` | `(thread: Thread) => void` | Set thread manually | ### useDatasetContents Fetch dataset contents using plain React state (no React Query required): ```tsx theme={null} import { useDatasetContents } from '@erdoai/ui'; function DataView({ datasetSlug, invocationId }) { const { data, isLoading, error, refetch } = useDatasetContents(datasetSlug, invocationId); if (isLoading) return
Loading...
; if (error) return
Error: {error.message}
; return (
{JSON.stringify(data, null, 2)}
); } ``` The hook uses the `DataFetcher` from the provider if available, otherwise falls back to the REST API. ### useMultipleDatasetContents Fetch multiple datasets in parallel: ```tsx theme={null} import { useMultipleDatasetContents } from '@erdoai/ui'; function MultiDataView({ invocationId }) { const results = useMultipleDatasetContents( ['dataset-1', 'dataset-2'], invocationId ); const isLoading = results.some(r => r.isLoading); const allData = results.map(r => r.data || []); if (isLoading) return
Loading...
; return
{JSON.stringify(allData, null, 2)}
; } ``` ## Content Renderers ### Content Auto-routes to the appropriate renderer based on content type: ```tsx theme={null} import { Content } from '@erdoai/ui'; function ResultView({ content }) { return ; } ``` #### Custom Renderers Override how specific content types are rendered using the `components` prop: ```tsx theme={null} import { Content } from '@erdoai/ui'; // Custom component receives { content, className } props function MyBotInvocationRenderer({ content, className }) { return (

Bot: {content.content.bot_name}

{/* Custom rendering logic */}
); } function ResultView({ content }) { return ( ); } ``` The `components` prop is a `Record` where: * **Key**: The `content_type` or `ui_content_type` string (e.g., `'bot_invocation'`, `'text'`, `'chart'`) * **Value**: A React component that receives `{ content, className }` props When Content encounters a content type, it first checks if there's a custom renderer in `components`, then falls back to built-in renderers. ### Individual Renderers ```tsx theme={null} import { TextContent, JsonContent, MarkdownContent, TableContent, ChartContent, } from '@erdoai/ui'; // Text // JSON // Markdown // Table ``` ## Chart Components All charts support download functionality via the download button. ### BarChart ```tsx theme={null} import { BarChart } from '@erdoai/ui'; ``` ### LineChart ```tsx theme={null} import { LineChart } from '@erdoai/ui'; ``` ### PieChart ```tsx theme={null} import { PieChart } from '@erdoai/ui'; ``` ### ScatterChart ```tsx theme={null} import { ScatterChart } from '@erdoai/ui'; ``` ### HeatmapChart ```tsx theme={null} import { HeatmapChart } from '@erdoai/ui'; ``` ## Chart Props All chart components accept these common props: | Prop | Type | Description | | ------------------- | -------------------- | ------------------------------------ | | `title` | `string` | Chart title | | `subtitle` | `string` | Optional subtitle | | `data` | `any[]` | Data array | | `displayConfig` | `ChartConfig` | Display configuration | | `dataConfig` | `DataConfig` | Data mapping configuration | | `stacked` | `boolean` | Stack series (bar charts) | | `disableAnimation` | `boolean` | Disable animations | | `enableDownload` | `boolean` | Show download button (default: true) | | `onDownloadSuccess` | `(fileName) => void` | Download success callback | | `onDownloadError` | `(error) => void` | Download error callback | | `onZoomChange` | `(domain) => void` | Zoom change callback | ## Styling `@erdoai/ui` components use Tailwind CSS classes and CSS custom properties (variables). You need to configure both for the components to render correctly. ### 1. Tailwind Configuration Add `@erdoai/ui` to your Tailwind content paths: ```js theme={null} // tailwind.config.js module.exports = { content: [ './node_modules/@erdoai/ui/**/*.js', // ... your content paths ], }; ``` ### 2. CSS Variables Components rely on CSS variables for theming. If you're using [shadcn/ui](https://ui.shadcn.com/), these are already defined. Otherwise, add them to your global CSS: ```css theme={null} /* globals.css or app.css */ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ``` ### Using with shadcn/ui If you're already using shadcn/ui, the CSS variables are configured automatically. Just ensure `@erdoai/ui` is in your Tailwind content paths. ### Minimal Setup (No shadcn/ui) If you're not using shadcn/ui but want Erdo charts to work: 1. Add the CSS variables above to your global styles 2. Configure Tailwind content paths 3. Components will use your theme colors automatically The `--chart-1` through `--chart-5` variables are used for chart colors. Customize these to match your brand. ## Advanced: Custom Content Renderers The `Content` component handles all content types automatically. For advanced use cases, you can override specific content type renderers using the `components` prop. ### Building Block Components When building custom renderers that need to display nested content (like bot invocations with steps), these building block components are available: ```tsx theme={null} import { InvocationEvents, // Renders nested steps/outputs StepInvocation, // Renders a single step Output, // Renders output contents } from '@erdoai/ui'; ``` ### Example: Custom Bot Invocation Renderer ```tsx theme={null} import { Content, InvocationEvents } from '@erdoai/ui'; function MyBotInvocationRenderer({ content, className }) { const data = content.content; const botInvocation = content.botInvocation; return (

Custom Bot: {data.bot_name}

{botInvocation && ( )}
); } // Use in Content ``` Most applications don't need custom renderers. The default `Content` component handles all standard content types including nested bot invocations, charts, tables, and markdown. # Vercel AI SDK Integration Source: https://docs.erdo.ai/ts-sdk/vercel-ai-sdk Use Erdo's MCP tools with Vercel AI SDK for rich data analysis in your chat UI # Vercel AI SDK Integration Connect Erdo's MCP tools to any LLM (Claude, GPT, etc.) using [Vercel AI SDK](https://sdk.vercel.ai). Your users ask data questions in a chat UI, the LLM calls Erdo tools to analyze data, and you render rich charts and tables with `ErdoToolResult`. ``` Browser (useChat) → Your API Route → LLM → Erdo MCP Server → Tool Results ↓ Browser ← streamed response ← LLM + tool results ←────────────────┘ ``` ## Installation ```bash npm theme={null} npm install @erdoai/ui ai @ai-sdk/react @ai-sdk/mcp @ai-sdk/anthropic ``` ```bash yarn theme={null} yarn add @erdoai/ui ai @ai-sdk/react @ai-sdk/mcp @ai-sdk/anthropic ``` ```bash pnpm theme={null} pnpm add @erdoai/ui ai @ai-sdk/react @ai-sdk/mcp @ai-sdk/anthropic ``` Replace `@ai-sdk/anthropic` with your preferred model provider (`@ai-sdk/openai`, `@ai-sdk/google`, etc.). ## Quick Start ### 1. Server Routes Two server routes: one for the LLM chat stream, one to create a scoped token for client-side data fetching (charts and tables). ```typescript theme={null} // app/api/chat/route.ts import { createMCPClient } from '@ai-sdk/mcp'; import { streamText, convertToModelMessages, type ToolSet } from 'ai'; import { anthropic } from '@ai-sdk/anthropic'; export async function POST(req: Request) { const { messages } = await req.json(); const mcpClient = await createMCPClient({ transport: { type: 'http', url: `${process.env.ERDO_ENDPOINT || 'https://api.erdo.ai'}/mcp`, headers: { Authorization: `Bearer ${process.env.ERDO_AUTH_TOKEN}`, }, }, }); const tools = await mcpClient.tools(); const modelMessages = await convertToModelMessages(messages); const result = streamText({ model: anthropic('claude-sonnet-4-5'), messages: modelMessages, tools: tools as ToolSet, system: 'You are a helpful data analyst. Use the Erdo tools to answer data questions.', onFinish: async () => { await mcpClient.close(); }, }); return result.toUIMessageStreamResponse(); } ``` ```typescript theme={null} // app/api/erdo-token/route.ts import { ErdoClient } from '@erdoai/server'; const erdoClient = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); export async function POST() { const { token } = await erdoClient.createToken({ botKeys: [], // Leave empty to grant access to all bots expiresInSeconds: 3600, }); return Response.json({ token }); } ``` Never expose your `ERDO_AUTH_TOKEN` to the browser. The MCP connection and token creation happen server-side only. ### 2. Render Results (Client) Fetch a scoped token on mount and pass it to `ErdoProvider`. Charts and tables use this token to fetch dataset contents. ```tsx theme={null} // app/chat/page.tsx 'use client'; import { useEffect, useState } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport, isToolUIPart } from 'ai'; import { ErdoProvider, ErdoToolResult, isErdoTool } from '@erdoai/ui'; export default function ChatPage() { const [token, setToken] = useState(null); useEffect(() => { fetch('/api/erdo-token', { method: 'POST' }) .then(res => res.json()) .then(data => setToken(data.token)); }, []); const { messages, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); const isStreaming = status === 'streaming' || status === 'submitted'; return (
{messages.map((message) => (
{message.parts.map((part, i) => { if (isToolUIPart(part) && isErdoTool(part)) { return ; } if (part.type === 'text' && part.text) { return

{part.text}

; } return null; })}
))}
{ e.preventDefault(); const input = e.currentTarget.querySelector('input') as HTMLInputElement; if (input.value.trim()) { sendMessage({ text: input.value.trim() }); input.value = ''; } }}>
); } ``` ### 3. Set Environment Variables ```bash theme={null} # .env.local ERDO_AUTH_TOKEN=your-api-key # Server-side only ERDO_ENDPOINT=https://api.erdo.ai # Optional, defaults to https://api.erdo.ai NEXT_PUBLIC_ERDO_ENDPOINT=https://api.erdo.ai # Client-side, for ErdoProvider ``` That's it. The LLM will automatically use Erdo tools like `erdo_list_datasets`, `erdo_ask_data_question`, and `erdo_query_data` based on user questions. ## Bridge Pattern (Alternative) If you're already using `@erdoai/server`, you can use `client.getTools()` instead of creating an MCP client directly: ```typescript theme={null} // app/api/chat/route.ts import { ErdoClient } from '@erdoai/server'; import { streamText, convertToModelMessages, type ToolSet } from 'ai'; import { anthropic } from '@ai-sdk/anthropic'; const erdoClient = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN, }); export async function POST(req: Request) { const { messages } = await req.json(); // getTools() connects to Erdo's MCP server and returns AI SDK-compatible tools const { tools, close } = await erdoClient.getTools(); const modelMessages = await convertToModelMessages(messages); const result = streamText({ model: anthropic('claude-sonnet-4-5'), messages: modelMessages, tools: tools as ToolSet, system: 'You are a helpful data analyst. Use the Erdo tools to answer data questions.', onFinish: close, }); return result.toUIMessageStreamResponse(); } ``` `getTools()` requires `@ai-sdk/mcp` as a peer dependency. Install it with `npm install @ai-sdk/mcp`. The client-side rendering code is the same — use `isErdoTool` + `ErdoToolResult` as shown above. ## Component Reference ### `isErdoTool(part)` Checks if an AI SDK message part is an Erdo tool call/result. ```typescript theme={null} import { isErdoTool } from '@erdoai/ui'; // Works with both static and dynamic tool parts: // - Static: { type: 'tool-erdo_list_datasets', ... } // - Dynamic (MCP): { type: 'dynamic-tool', toolName: 'erdo_list_datasets', ... } isErdoTool(part) // → true for any erdo_* tool ``` ### `ErdoToolResult` Renders an Erdo tool result with appropriate UI based on the tool type: * **UI tools** (`erdo_render_chart`, `erdo_render_table`): Charts and tables via UIGenerationNodes * **Markdown tools** (`erdo_ask_data_question`): Text answer rendered as markdown * **Data tools** (`erdo_list_datasets`, `erdo_get_dataset_schema`, etc.): Formatted JSON * **Loading states**: Spinner with tool name * **Errors**: Error message display ```tsx theme={null} import { ErdoToolResult, type ErdoToolResultProps } from '@erdoai/ui'; ``` ### `getErdoToolName(part)` Extracts the Erdo tool name from an AI SDK message part, or returns `undefined` if it's not an Erdo tool. ```typescript theme={null} import { getErdoToolName } from '@erdoai/ui'; getErdoToolName(part) // → 'erdo_list_datasets' | 'erdo_ask_data_question' | ... ``` ### `ErdoProvider` Required for chart and table rendering. Provides the data fetching context so charts can load dataset contents. Pass a scoped token created from your backend (see quick start above). ```tsx theme={null} import { ErdoProvider } from '@erdoai/ui'; {children} ``` If you don't need chart/table rendering (only text and JSON results), `ErdoProvider` is optional. ## Authentication The API key (`ERDO_AUTH_TOKEN`) stays server-side for the MCP connection and token creation. The client gets a short-lived scoped token for data fetching — this is already set up in the quick start above. For more advanced token scoping (restricting to specific datasets or users), see [Scoped Tokens](/ts-sdk/integration#scoped-tokens-b2b2c). ## Available Tools When connected via MCP, the LLM can use these Erdo tools: | Tool | Description | | ----------------------------- | --------------------------------------------------- | | `erdo_list_datasets` | List datasets with name, type, and description | | `erdo_get_dataset_schema` | Get column names, types, and statistics | | `erdo_gather_dataset_context` | Get context for multiple datasets at once | | `erdo_search_data` | Search across datasets with natural language | | `erdo_ask_data_question` | AI analysis — returns a text answer | | `erdo_render_chart` | Render a chart (bar, line, pie, histogram, scatter) | | `erdo_render_table` | Render a data table | | `erdo_query_data` | Natural language SQL queries | | `erdo_run_query` | Execute raw SQL queries | See [MCP Server](/mcp/overview#available-tools) for full parameter details. ## Example App A complete working example is available in the SDK repository: ```bash theme={null} git clone https://github.com/erdoai/erdo-ts-sdk cd erdo-ts-sdk/examples/nextjs-vercel-ai ``` It demonstrates three integration patterns: * **MCP Pattern** (`/mcp`) — MCP tools via AI SDK with `ErdoToolResult` rendering * **Token Pattern** (`/`) — Direct streaming with ephemeral tokens * **Proxy Pattern** (`/proxy`) — Server-proxied SSE streaming # Voice Calls & Scheduling Source: https://docs.erdo.ai/voice Have an Erdo agent place real outbound phone calls — qualify a lead, confirm a detail, and book a meeting on your calendar, all in one call. # Voice Calls & Scheduling Erdo agents can place real outbound phone calls on your behalf. You tell an agent who to call and what to accomplish; it dials out, holds a natural spoken conversation, and reports back with a transcript. If the call involves scheduling, the agent can check **your** calendar availability live and book the meeting before hanging up. A call is always **you asking an agent to make it** — in chat, the agent proposes the call and you approve it before anything dials out. Erdo never places calls on its own. ## What you can do * **Qualify or follow up with a lead** — "Call this number, confirm they still want a demo, and note their timeline." * **Confirm a detail** — "Call the venue and check they have availability for 40 people on the 14th." * **Book a meeting on the call** — "Call this lead and, if they're interested, book a 30-minute intro on my calendar this week." ## Before you start | To do this | You need | | ----------------------- | --------------------------------------------------------------- | | Place calls | Erdo Voice enabled for your organization | | Book meetings on a call | Your **Google Calendar** connected in **Settings → Connectors** | Booking uses **the calendar of the person who asked for the call** — yours. The agent checks your availability and creates the event on your calendar, adding the person you called as a guest. If your Google Calendar isn't connected, an agent asked to book will tell you to connect it first (and won't place the call), so connect it up front when scheduling is the goal. ## Connect your calendar Find **Google Calendar** in the connector list. Sign in with Google and approve calendar access. That's it — the connection is per user, so each person who wants the agent to book on their behalf connects their own calendar. ## Make a call Just ask an agent in chat. Give it the number, what to say, and — for compliance — why the call is allowed (an opt-in, an existing relationship, an internal test, etc.). For scheduling, mention it explicitly and include the recipient's email if you want them invited. > "Call **+1 555 010 0142** (Jordan at Acme). Confirm they're still interested in > the pilot, and if so book a **30-minute intro on my calendar** sometime next > week. They opted in on our site. Invite **[jordan@acme.com](mailto:jordan@acme.com)**." The agent will: You see who it will call and why, and approve before it dials. It introduces itself as an AI assistant, follows your instructions, and asks one thing at a time. It reads your real free/busy times, offers slots that actually work, and creates the calendar event with the recipient invited. Ask for the transcript afterward — "What did they say on the call?" — to see exactly what happened. ## Give an agent its own phone number (inbound calls) Open **Agents → Deployments** to see voice runtimes and phone numbers alongside the agent's website deployments. Choose **Voice or phone** there to start guided setup for an existing agent; Erdo will help cast the language and voice before it creates anything. By default a voice agent places **outbound** calls from Erdo's shared number. If you want an agent to **receive** calls too, ask Erdo to give it its own phone number — *"give my Sofia agent a phone number"*. Erdo buys a number, assigns it to that agent, and from then on calls to it are answered by that agent (and it also becomes the agent's outbound caller ID). It's approval-gated, since it adds a number to your account. Agents running on your **own** connected voice provider account are the exception — assign a number to those in that account directly. ## Good to know * **Consent matters.** Every call needs a stated reason it's permitted; the agent won't place a call without one. If the person asks not to be called again, the agent acknowledges it and ends the call. * **The agent never claims to be human.** It identifies as an AI assistant. * **Booking won't guess.** If the agent can't read your calendar (not connected, or access revoked), it tells you rather than booking over your existing events. * **One calendar per call.** Availability and the booked event belong to whoever asked for the call — not the person being called. ## Troubleshooting | You see | What to do | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------- | | "Connect your account in Settings → Connectors" | Connect (or reconnect) Google Calendar there, then ask again. | | The agent says it can't book | Confirm your Google Calendar is connected and the connection is healthy in Settings → Connectors. | | Nothing happens after you ask | Voice may not be enabled for your organization — contact your Erdo admin. | # Website Widget (concierge) Source: https://docs.erdo.ai/voice-widget Embed an AI concierge on your website — visitors can talk, chat, or video with it, grounded in your content. Configure its modes, voice, and branding from the dashboard, let it book meetings on your calendar, and review every conversation. # Website Widget Add a floating assistant to your website so visitors can **talk, chat, or video** with an AI concierge — ask questions, get guided, and hear answers grounded in your own content. You create and configure the widget in Erdo, then drop one line of code on your site. Everything runs through Erdo; your site never loads a third-party SDK or holds any keys. The widget is a **website embed**, separate from [outbound phone calls](/voice). Same agent technology, different surface: here a visitor starts the conversation from your page. ## Create a website deployment Everything below — modes, voice, and branding — is configured in **Agents → Deployments**, so the embed snippet stays a single line and you never have to hand-edit code to change how the widget looks or sounds. A deployment is attached to a durable Erdo agent: edit the agent once to change its identity and instructions everywhere it is deployed. Website visitors can search only knowledge your organization has explicitly marked **Public**. Click **Website**, choose the agent visitors should meet, and give the deployment a name. Pick any combination of **Voice** (talk out loud), **Chat** (type messages), and **Video** (an on-screen avatar). With more than one selected, visitors get a switcher to move between them. Search the voice library and **preview** candidates, then select one — or leave it on the default. This is the spoken voice for the voice mode. When **Video** is enabled, choose the on-screen avatar visitors see. The picker shows each available avatar as a thumbnail that plays a short preview on hover — select one, or leave it on the default avatar. Set its greeting and any channel-specific compliance guardrails. What the concierge does and how it behaves come from the attached agent's Instructions. Set the accent colour and the header / button / intro copy. A **live preview** in the dialog shows your changes before you save — see [Match your brand](#match-your-brand). Connect a calendar and enable meeting scheduling so the assistant can offer real open slots and book meetings during the conversation — see [Book meetings on your calendar](#book-meetings-on-your-calendar). Add the **allowed origins** for your site — see [Allowed origins](#allowed-origins) below. Leave it empty while testing. Each widget has a one-line ` ``` It adds a floating launcher at the bottom of the page — a compact card with the concierge's animated **orb** (or a **face**, if you pick one) above a call-to-action — and nothing else on your page changes. Visitors tap it to start. The widget's modes, voice, and branding all come from its settings — the snippet itself never needs to change when you tweak them. To keep the corner unobtrusive, the card **folds into a small pill** once the visitor starts reading — by default when they've scrolled 10% of the way down the page (tune it with `data-erdo-minimize-scroll`, below), or after 5 seconds on a page they don't scroll, whichever comes first. The card morphs into the pill in one continuous motion — just the orb, the call-to-action, and the mode it offers. Hovering the pill brings the full card back; moving away folds it again. Tapping either the pill or the card opens the widget exactly the same way, and the fold respects a visitor's *reduce motion* setting (it snaps between states instead of animating). By default the launcher shows the plain animated orb. In the widget's **Appearance** settings you can give the concierge a face instead — pick one of a set of preset faces. A **video** widget is the one exception: it always shows its selected avatar's own photo, so the launcher matches the avatar visitors are about to meet. Whatever you choose appears inside the conversation too — in the orb a visitor taps to start a call and while the call is live — so the concierge stays one consistent presence. In the dashboard, each widget has an **embed builder** ("Customize" next to the snippet). It generates this snippet for you and, if you want per-page tweaks, adds the `data-erdo-*` overrides below — hide the launcher, set a language, or override copy/colours — with a live preview. Leave a field blank to inherit the widget's saved setting. Pages you build **inside Erdo** (the "make me a page" flow) that embed a widget work automatically — Erdo's own page hosts are always allowed, so you don't need to add them to the allowed origins. ## Preview your widget on a demo page Before you drop the snippet on your own site — or when you just want to show a colleague — each website deployment card in **Agents → Deployments** has a **Preview** button. It opens a public demo page in a new tab: a placeholder website for a fictional real-estate development with **your** widget embedded on it, running exactly as it would on a real page. The widget pulls its modes, voice, and branding from its settings and grounds its answers in the demo site's content, so you can start a conversation and ask about the development's prices, viewing hours, or buying process to hear how the concierge behaves in context. The demo page is a **shareable, no-login link** — anyone you send it to can open it and try the widget without an Erdo account (the same `vw_` key that's designed for public embedding). It's the quickest way to get sign-off before you touch your own site's code. The page has a **Floating / Inline panel** toggle at the top so you can see both embed styles: the default floating launcher in the bottom corner, and the always-open [inline panel](#embed-it-inline-as-a-panel-no-floating-bubble) that fills a section of the page. Switching between them reloads the demo with that style applied. The demo works even while your **allowed origins** are locked down to your own site — Erdo's own hosts are always allowed, so the demo page never needs to be in the list. If the widget is **disabled**, the demo page still loads the placeholder site but shows a banner instead of the widget; enable the widget to try it. ## Conversation modes — voice, text, and video One widget, one snippet — the same assistant can **talk** (voice), **chat** (text), or appear as a **video** agent. You choose which modes a widget offers; all three are answered by the **same** assistant, grounded in the same Knowledge, so the experience is consistent however a visitor reaches out. | Mode | What the visitor does | Needs | | --------- | -------------------------------------------------- | ------------------- | | **Voice** | Taps and talks; hears a spoken reply | Microphone | | **Text** | Types a message; reads replies (with images/cards) | Nothing | | **Video** | Speaks with an on-screen avatar in real time | Microphone + camera | When a widget offers more than one mode, visitors see a small **Talk / Chat / Video** switcher; with a single mode they just get that experience. Permissions are only requested when a visitor actually starts that mode — a voice- or text-only widget never prompts for the camera. For the **Video** mode you can also pick which on-screen **avatar** appears (see the avatar step above); leave it on the default to use Erdo's standard avatar. **Your existing snippet doesn't change.** Turning on text or video for a widget is a setting on the widget itself — the ` ``` Analytics come from **Erdo's** widget embed. A page that embeds a voice provider directly, rather than through the Erdo snippet, runs none of Erdo's code and so sends none of these events. ## Match your brand Set the widget's accent colour and copy in **Agents → Deployments** — the dialog shows a **live preview** as you type, and the branding is saved on the widget, so the same one-line snippet always reflects your latest look: | Setting | What it does | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Accent colour** | Your brand colour — drives the orb, the launcher glow, the active mode tab, focus rings, chat bubbles and the send button. | | **Header title** | Text in the panel header. | | **Start / End button labels** | The call button wording. | | **Intro line** | Short line shown before the conversation starts. | | **Launcher face** | The face shown on the launcher and in the in-conversation orb — choose from a set of preset faces, or leave it on the default: the plain animated orb with no face. **Video** widgets ignore this and follow their selected avatar's photo automatically. | The widget runs in a sandboxed frame, so its inside is styled by these settings, not your page's CSS. To target the widget's **container** (position, size, z-index) from your own CSS, it has a stable id/class: `#erdo-voice-widget` / `.erdo-voice-widget`. ### Per-page overrides (advanced) If a single widget appears on pages that need different branding, you can override any branding field for one page with a `data-erdo-*` attribute on the embed script. A present attribute wins over the widget's saved setting; anything you omit falls back to the saved value. ```html theme={null} ``` | Attribute | Overrides | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data-erdo-accent` | Your brand colour (hex) — drives the orb, launcher glow, active mode tab, focus rings, chat bubbles and the send button. | | `data-erdo-bg` | Panel background (hex). Set a dark value to run the widget dark. | | `data-erdo-text` | Main text colour (hex). | | `data-erdo-muted` | Secondary / status text colour (hex). | | `data-erdo-border` | Card and control border colour (hex). | | `data-erdo-title` | Header text. | | `data-erdo-start-label` / `data-erdo-end-label` | Call button labels. | | `data-erdo-intro` | Short greeting line shown before the call starts. | | `data-erdo-cta` | Launcher headline. Set it to suit the page — e.g. `Ask about this home`, `Ask about this building`, `Ask about our menu`. Defaults to `Ask a question`. | | `data-erdo-tagline` | Launcher sub-line. Defaults to `Voice AI · replies instantly`. | | `data-erdo-face` | Path to a preset face image (e.g. `/agents/Agent1.jpeg`) to show on the launcher and in the orb for this page, or `none` for the plain orb with no face. Overrides the widget's configured face — and, because a per-page face always wins, it also overrides a video widget's avatar-derived face on that page. Leave it off to inherit the widget's setting. | | `data-erdo-launcher` | Set to `none` to hide the built-in launcher. The widget mounts hidden and you open it from your own button or script (see below). | | `data-erdo-minimize-scroll` | How far down the page (percent, `1`–`100`) a visitor scrolls before the launcher card folds into the compact pill. Defaults to `10`. | | `data-erdo-embed` | Set to `panel` to embed the widget inline as an always-open panel that fills a container, instead of a floating launcher (see [Embed it inline as a panel](#embed-it-inline-as-a-panel-no-floating-bubble)). | | `data-erdo-target` | With `data-erdo-embed="panel"`, a CSS selector for the container to mount the panel into (e.g. `#concierge`). Defaults to the embed script's parent element. | | `data-erdo-lang` | Language as an ISO 639-1 code (e.g. `es`, `pt`) — translates the widget's own chrome and makes the voice/video assistant speak that language. If omitted, the widget inherits the page's ``. See [Language & translation](#language--translation). | Together, `data-erdo-bg` / `text` / `muted` / `border` (plus `accent`) let you run the widget in a fully dark or branded palette. Colours must be hex; anything else is ignored and the default is kept. Style only the **container box** (position, size, z-index, shadow) with your CSS. Never apply a colour `filter` — `hue-rotate`, `invert`, `sepia`, `grayscale` — to the widget element to recolour it. A filter on the iframe re-tints its **entire** rendered surface, including the photos the assistant shows during a call, so a blue sky turns orange and green turns purple. Use the `data-erdo-*` colour attributes above instead — they restyle the chrome without ever touching the images. ## Language & translation The widget speaks your visitor's language on two levels: its own **chrome** (buttons, status, hints, placeholders) and the **assistant's replies**. **It follows the page automatically.** If your page declares a language — ``, `` — the widget picks it up with no extra configuration: the chrome is translated, and both the **voice** and **video** avatar conversations speak that language. Set `data-erdo-lang` on the embed to override the page's language for the widget specifically: ```html theme={null} ``` **Your own copy always wins.** Any text you customized — in the widget settings or via a `data-erdo-*` attribute (title, intro, launcher CTA/tagline, button labels) — is shown exactly as you wrote it and is never auto-translated. Only the copy you leave at its default gets the built-in translation. So a single widget can serve a localized page in that language out of the box, while still honouring any wording you set yourself. Translations currently ship for a starter set of languages; any language not yet translated falls back to English per string (a partial translation never shows a blank). **Give each language its own voice.** In the widget's settings, under **Voice & languages**, you set a primary language and can add extra languages — up to 10 in total — each with its own voice picked from the voice library. The agent then answers every configured language in that language's voice (so Spanish sounds like a native Spanish speaker, not an English voice reading Spanish), and your greeting is translated into each language automatically. This is one agent serving all of them: a `data-erdo-lang` page (or a visitor who switches language) is met in the right voice with no per-page setup. Leave the extra languages empty and the widget stays single-language. **It switches mid-conversation, too.** On a multi-language widget the assistant detects the language the visitor is actually speaking: start in English, answer in English; switch to Spanish — or simply ask for Spanish — and the assistant follows, voice and all, without restarting the conversation. Detection only ever switches between the languages you configured, so the assistant never wanders into a language you haven't given a voice. ## Open it from your own button, script, or agent By default the widget shows a floating launcher. You can also drive it yourself — useful for a custom "Talk to us" button, a help menu, or an in-page assistant. **Declaratively** — add a `data-erdo-voice-*` attribute to any element; clicking it controls the widget (no JavaScript needed): ```html theme={null} ``` **Programmatically** — call the global API from any script: ```js theme={null} window.ErdoVoiceWidget.open(); // open the panel and start the call window.ErdoVoiceWidget.close(); // close it window.ErdoVoiceWidget.toggle(); // open if closed, close if open window.ErdoVoiceWidget.expand(); // open fullscreen (roomier, larger photos) window.ErdoVoiceWidget.collapse(); // back from fullscreen to the panel ``` Opening the widget — whether the visitor taps the launcher or you call `open()` / `toggle()` / `expand()` — goes straight into the call: on a voice widget it asks for the microphone and connects, so there's no second tap on the orb. If the visitor declines the microphone prompt, the call ends and the widget stays open on its maximized screen, where they can type a question in chat or tap the orb to try the call again. (Because a browser only starts audio in response to a real click, call `open()` from within a click handler so the microphone prompt appears reliably.) Calls made before the widget finishes loading are queued and run once it's ready. Visitors can also expand to fullscreen with the ⤢ button in the widget header, and tap any photo to view it full-size. ### Hide the built-in launcher and use your own call-to-action Add `data-erdo-launcher="none"` to the embed to suppress the floating pill entirely. The widget mounts hidden — no launcher, and nothing that intercepts clicks — so your page owns the call-to-action and opens the widget whenever you choose: ```html theme={null} ``` Or open it from a script — for example on scroll, after a delay, or from your own UI: ```js theme={null} window.ErdoVoiceWidget.open(); ``` **Keep your CTA in sync.** The widget can be closed by the visitor too (the ✕, the Escape key, or clicking outside it). Listen for state changes so your button always reflects whether the widget is open: ```js theme={null} document.addEventListener('erdo-voice:state', function (e) { // e.detail.open is true when the widget is open, false when closed myButton.classList.toggle('is-active', e.detail.open); }); // Or register a callback / read the current state directly: window.ErdoVoiceWidget.onState(function (open) { /* ... */ }); window.ErdoVoiceWidget.isOpen; // boolean ``` ### Embed it inline as a panel (no floating bubble) Add `data-erdo-embed="panel"` to mount the widget **inline, as an always-open panel that fills a container** — a concierge section, a sidebar, a modal you own — instead of a floating launcher. Point it at the container with `data-erdo-target="#your-element"` (or, with no target, it mounts into the embed script's own parent element): ```html theme={null}
``` The panel is always open, has no launcher pill, and never takes over the viewport — the container controls its size (the iframe fills its width and height, so give the container a height). The panel shows its own **Start call** button on an orb: voice **and** video both start on a tap, never automatically — so the panel can sit in the page without grabbing the microphone on load. Every appearance attribute above still applies, including the dark panel chrome (`data-erdo-bg` / `-text` / `-muted` / `-border`) if you want it to read as part of the section rather than a light box. ## Allowed origins **Allowed origins** restrict which websites may embed this widget, so someone can't copy your snippet onto their own site and run up your usage. Leave the list **empty to allow any site** (handy while testing); add origins to lock it down — up to 50 per widget. The rules — the widget shows an error and won't start on a site that doesn't match: * **Scheme + domain only.** Enter the full origin including `https://`, with no path: `https://acme.com`, not `acme.com` and not `https://acme.com/contact`. * **No wildcards.** `*.acme.com` is not supported. * **Matching is exact, and subdomains count as different sites.** `https://acme.com` does **not** cover `https://www.acme.com` or `https://shop.acme.com` — add each one you actually use, on its own line. * Matching ignores case and a trailing `/`. The most common mistake: adding `https://acme.com` but serving your site from `https://www.acme.com` (or vice-versa). They're different origins — list every hostname your visitors actually load. **Example** — a site served from both the apex and `www`: ``` https://acme.com https://www.acme.com ``` ## Usage limits Each widget has a **daily session cap** (how many conversations it will start per day) to keep usage predictable — **500 per day** unless you change it, and `0` means unlimited. Every mode counts toward the same cap — a voice call, a text chat, and a video session each use one conversation. When the cap is reached, the widget tells visitors it's unavailable until the next day. Raise or lower it in the widget's settings. ## Troubleshooting | You see | What to do | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | "This site is not allowed to embed this voice widget" | The page's origin isn't in **Allowed origins**. Add the exact origin (with `https://`, and the right subdomain — see above), or empty the list to allow any site. | | The mic button doesn't appear | Confirm the `