# Index One documentation > Complete developer documentation for the Index One REST API and MCP server. > Generated from https://indexone.io/docs. --- # Overview > What Index One does and which surface to reach for. Index One is infrastructure for running rules-based portfolios. You describe a strategy once — a universe, a selection rule, a weighting scheme, a rebalance schedule — and the platform calculates it every session, keeps the history, handles corporate actions, and pushes the results wherever they need to go. That covers a wider range of work than "index provider" suggests. The same machinery runs a passive fund's benchmark, a systematic manager's live signal, a direct-indexing book with thousands of per-account variants, and a one-off custom benchmark for a mandate. ## Three surfaces The same engine is reachable three ways — pick whichever suits the caller. The two programmatic surfaces are equivalent in power; the browser covers the common cases without any code at all. - [REST API](/docs/start/quickstart) — Ordinary HTTP for applications and scheduled jobs. Start with the quickstart. - [MCP server](/docs/mcp/overview) — The same capabilities as tools an AI agent can call directly, including building and deploying indices. - [The Index Studio](/docs/guides/basket-index) — Build, backtest and download an index in the browser. No key, no code. ## The shape of the system A **workflow** is the definition of an index: a directed graph of **operations** that runs on a schedule. Operations pull data, filter and rank it, and produce the three artifacts an index needs — a **universe** (what is eligible), a **weighting** (target proportions) and **holdings** (actual share counts, adjusted for corporate actions). From holdings, the engine calculates a **value** series each session. A **delivery** is a separate scheduled graph that takes those artifacts and sends them somewhere: an email attachment, a webhook, an SFTP drop. ## Where to go next - [Build a basket index (no code)](/docs/guides/basket-index) — Upload a file of holdings and weights and backtest it in the browser. - [Quickstart](/docs/start/quickstart) — Get a key and pull real index data in about five minutes. - [Core concepts](/docs/start/concepts) — The vocabulary the rest of these docs assumes. - [Build and backtest an index](/docs/guides/build-and-backtest) — Assemble an operation graph and simulate it. - [Set up a delivery](/docs/guides/deliveries-setup) — Push holdings to a counterparty on a schedule. - [API reference](/docs/reference/workflows) — Every endpoint, with a runnable console. - [Docs for agents](/docs/agents/overview) — llms.txt, OpenAPI and MCP entry points. --- # Quickstart > Pull your first index values in about five minutes. This walks from nothing to real index data. It uses public endpoints where it can, so you can follow most of it before you have an account. ## Look around without a key The operation catalog is public. It is the authoritative list of every building block a workflow can use — 68 of them at the time of writing — and needs no credentials at all. ```bash curl https://api.indexone.io/schema ``` That returns `{"operation_manifest": { … }}`, keyed by operation id. The [operation catalog](/docs/agents/operations) page renders the same data with search and parameter tables. ## Get an API key 1. **Create an account** Sign up in the [console](/register). Keys belong to a team, and every account starts with one. 2. **Open Team → API Keys** Go to [API keys](/teamkeys) and create a key. It is shown in full — copy it now. 3. **Paste it into these docs** Every endpoint on the reference pages has a **Try it** panel. Paste the key into the credentials box at the top of the page and requests run from your browser against the live API. The key is kept in this tab only and never sent anywhere except `api.indexone.io`. ## Make your first authenticated call List the workflow indices your team owns. Authenticated endpoints take your API key in `x-api-key` **and** a Cognito id token in `Authorization` (sent raw, with no `Bearer ` prefix) — [Authentication](/docs/start/authentication) covers how to get the token. **cURL** ```bash curl "https://api.indexone.io/workflows" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Python** ```python import requests response = requests.get( "https://api.indexone.io/workflows", headers={ "x-api-key": "YOUR_API_KEY", "Authorization": "YOUR_ID_TOKEN", }, ) response.raise_for_status() for index in response.json(): print(index["id"], index.get("name")) ``` **JavaScript** ```javascript const response = await fetch("https://api.indexone.io/workflows", { headers: { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN", }, }); const indices = await response.json(); console.log(indices); ``` **TypeScript** ```typescript const response = await fetch("https://api.indexone.io/workflows", { headers: { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN", }, }); const indices: unknown = await response.json(); console.log(indices); ``` **Go** ```go req, _ := http.NewRequest("GET", "https://api.indexone.io/workflows", nil) req.Header.Set("x-api-key", "YOUR_API_KEY") req.Header.Set("Authorization", "YOUR_ID_TOKEN") resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() ``` > **A 403 usually means the key** > > A `403` with `{"error": "Forbidden"}` almost always means a missing or invalid API key — the gateway > rejects those before the request reaches a handler. These rejections carry CORS headers, so a browser reads the > `403` directly rather than failing with an opaque network error. ## Read a live index Workflow indices (ids beginning `idx_`) store their data in a set of tables you read through `GET /query`. To pull a value series, ask for the `index-values-eod` table with the index id as the partition key. ```bash curl "https://api.indexone.io/query?table=index-values-eod&pk=idx_bh7fgXWJMaa3&order=descending&limit=10" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` > **Two credentials, not one** > > `/query` needs both an API key and a Cognito id token. The token is *not* prefixed with `Bearer `. > [Authentication](/docs/start/authentication) explains which endpoints need which, and how to get a token. ## Next - [Core concepts](/docs/start/concepts) — Universes, weightings, holdings and values. - [Pull live index data](/docs/guides/pull-index-data) — The full read path, with every artifact. - [Build and backtest an index](/docs/guides/build-and-backtest) — Write your first workflow. - [Connect an agent](/docs/mcp/connect) — Do all of this from Claude or Cursor. --- # Authentication > API keys, Cognito tokens, and when you need both. Index One uses two independent credentials. An **API key** identifies your team to the gateway. A **Cognito id token** identifies you as a user. They are not alternatives — most authenticated endpoints want both headers on the same request, and a request carrying only one of them is rejected. The split is historical rather than principled: the gateway enforces the key and the usage plan, while the Lambda handlers behind it read the token to work out who you are and which team's data you may touch. ## The two credentials | Header | Value | Issued by | What it proves | | --- | --- | --- | --- | | `x-api-key` | Team API key | Console → Team → [API Keys](/teamkeys) | Which team is calling, and which usage plan throttles it | | `Authorization` | Raw Cognito id token | `POST /signin` | Which user is calling | > **No `Bearer ` prefix** > > The `Authorization` header takes the id token *raw*. Sending `Authorization: Bearer eyJ…` — the habit > every other API trains — returns a 401. The one exception is the MCP server, which does accept > `Authorization: Bearer `. ## Getting an API key 1. **Open Team → API Keys** Keys belong to a team, not to a user. Any member of the team can mint one at [API keys](/teamkeys). 2. **Create the key** The value is shown in full at creation and again in the list. Keys carry a date-only `created_at` (`YYYY-MM-DD`), not a timestamp. 3. **Store it as a secret** A key is a bearer credential for the whole team. Anyone holding it can read and write your team's indices within the limits of the endpoints that accept key-only auth. > **Key values are readable after creation** > > `GET /teams/{id}/keys` returns key values in plaintext, so the console's key list is as sensitive as the > keys themselves. Treat access to it as equivalent to handing out every key, and do not screenshot or paste that > page into a ticket. ## Getting an id token `POST /signin` exchanges a username and password for a token set. The response carries `id_token`, `refresh_token` and `expires_in`. **cURL** ```bash curl -X POST "https://api.indexone.io/signin" \ -H "Content-Type: application/json" \ -d '{"username": "you@example.com", "password": "YOUR_PASSWORD"}' ``` **Python** ```python import requests response = requests.post( "https://api.indexone.io/signin", json={"username": "you@example.com", "password": "YOUR_PASSWORD"}, ) tokens = response.json() id_token = tokens["id_token"] refresh_token = tokens["refresh_token"] ``` **JavaScript** ```javascript const response = await fetch("https://api.indexone.io/signin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "you@example.com", password: "YOUR_PASSWORD" }), }); const { id_token, refresh_token, expires_in } = await response.json(); ``` **TypeScript** ```typescript interface TokenSet { id_token: string; refresh_token: string; expires_in: number; } const response = await fetch("https://api.indexone.io/signin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "you@example.com", password: "YOUR_PASSWORD" }), }); const tokens: TokenSet = await response.json(); ``` **Go** ```go payload := []byte("{\"username\":\"you@example.com\",\"password\":\"YOUR_PASSWORD\"}") resp, err := http.Post("https://api.indexone.io/signin", "application/json", bytes.NewReader(payload)) if err != nil { panic(err) } defer resp.Body.Close() ``` > **Sign-in failures carry a machine-readable code** > > A rejected sign-in returns `401` with the standard `{"error": …}` body plus a `code` — the Cognito > exception class (`NotAuthorizedException` for a bad password, `UserNotConfirmedException` for an unconfirmed > account). Branch on `code` rather than parsing the message. See [Errors](/docs/start/errors). ## Making an authenticated call Send both headers. This is the normal case for anything that touches team data. **cURL** ```bash curl "https://api.indexone.io/query?table=index-parameters&pk=idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Python** ```python import requests HEADERS = { "x-api-key": "YOUR_API_KEY", "Authorization": "YOUR_ID_TOKEN", # raw token, no "Bearer " } response = requests.get( "https://api.indexone.io/query", headers=HEADERS, params={"table": "index-parameters", "pk": "idx_bh7fgXWJMaa3"}, ) print(response.json()) ``` **JavaScript** ```javascript const headers = { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN", // raw token, no "Bearer " }; const response = await fetch( "https://api.indexone.io/query?table=index-parameters&pk=idx_bh7fgXWJMaa3", { headers }, ); console.log(await response.json()); ``` **TypeScript** ```typescript const headers: Record = { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN", // raw token, no "Bearer " }; const response = await fetch( "https://api.indexone.io/query?table=index-parameters&pk=idx_bh7fgXWJMaa3", { headers }, ); console.log(await response.json()); ``` **Go** ```go req, _ := http.NewRequest("GET", "https://api.indexone.io/query?table=index-parameters&pk=idx_bh7fgXWJMaa3", nil) req.Header.Set("x-api-key", "YOUR_API_KEY") req.Header.Set("Authorization", "YOUR_ID_TOKEN") // raw token, no "Bearer " resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() ``` ## What each endpoint needs | Endpoints | Credentials | | --- | --- | | `GET /schema` | None — the operation catalog is public | | `POST /signin`, `POST /refresh_token` | None — they issue the token | | `POST /execute`, `POST /simulate` | `x-api-key` | | Most workflow, delivery, dataset, API-key and query endpoints | Both `x-api-key` and `Authorization` | | `https://api.indexone.io/mcp` | `x-api-key`, or `Authorization: Bearer `, or Cognito OAuth 2.1 | > **Some endpoints started requiring a key on 21 July 2026** > > `POST /execute` and `POST /simulate` previously accepted requests with no credentials at all. They now > require a team API key. If you have an integration that called either without a key, it will get a 403 until you add > one. Nothing else about the request or response changed. ## Refreshing a token Id tokens expire — `expires_in` on the sign-in response tells you when. Exchange the refresh token for a new set with `POST /refresh_token`. ```bash curl -X POST "https://api.indexone.io/refresh_token" \ -H "Content-Type: application/json" \ -d '{"refresh_token": "YOUR_REFRESH_TOKEN"}' ``` > **A failed refresh returns 401** > > An expired or invalid refresh token returns `401` with `{"error": …}`. Treat it as "session over" and > re-authenticate with `POST /signin`. A successful refresh returns `200` with a fresh `id_token`. ## A missing or invalid key API Gateway rejects a missing or invalid API key before your request reaches any handler, returning `403` with `{"error": "Forbidden"}`. These gateway rejections carry CORS headers, so a browser `fetch` reads the status and body directly — the same `403` you would see from curl. ## Rotation and hygiene - Use a separate key per deployment target so you can revoke one without taking everything down. - Rotate by creating the new key, deploying it, confirming traffic has moved, and only then deleting the old one — there is no grace period on a deleted key. - Keep keys out of front-end bundles. A key in browser JavaScript is a team credential handed to every visitor. - Throttling is per key, so a shared key means one noisy job can rate-limit everything else on it. See [Rate limits & timeouts](/docs/start/rate-limits). - Store id tokens in memory rather than on disk; they are short-lived and refreshable. - [Errors](/docs/start/errors) — The single error shape and the status codes behind it. - [Rate limits & timeouts](/docs/start/rate-limits) — Per-key throttles and the 29-second ceiling. - [Connect an agent](/docs/mcp/connect) — Authenticating the MCP server from Claude or Cursor. --- # Core concepts > Workflows, operations, holdings, weightings, universes and deliveries. Everything else in these docs assumes this page. It is the vocabulary — what a workflow is, how operations wire together, the three artifacts an index produces, and how the newer engine differs from the one it replaced. ## Workflows A **workflow** is the definition of an index. Not a snapshot of its holdings — the *rules*: where the data comes from, which securities qualify, how they are weighted, and when the whole thing recalculates. A workflow has an id beginning `idx_` and lives in the `{stage}-index-parameters` table. Its body is `{name, start_time, exchange_calendar?, operations: [...]}`, where `operations` is a directed acyclic graph. Running that graph is an **execution**; the graph itself never changes between runs, only the data flowing through it. ## Operations An **operation** is one node in the graph. There are 68 of them, spread across 13 categories: trigger, dataset, data_transformation, index_management, statistics, control_flow, delivery, utility, ai, optimization, machine_learning, storage and viewer. The catalog is public and authoritative — never guess an operation name or parameter, read it from `GET /schema`. ```json { "id": "filter_large_caps", "operation": "filter_dataframe", "parameters": { "column": "market_cap", "operator": ">", "value": 1000000000 }, "input": [{ "$ref": "load_universe" }] } ``` Four fields, always. `id` is your own label and is what other operations point at. `operation` is the catalog name. `parameters` configures it. `input` lists the upstream operations whose output flows in. ## Wiring with $ref Edges are declared in `input` as `{"$ref": ""}`. To read a *column* from an upstream result inside a parameter, use the longer form `{"$ref": ".output#"}`. These two mechanisms are separate and both are required. A `$ref` buried in `parameters` does not create an edge — if the upstream operation is not also listed in `input`, the graph has no dependency and the value will not be there when the node runs. This is the single most common wiring mistake. ```json { "id": "weight_by_cap", "operation": "create_index_weighting", "parameters": { "weighting_type": "proportional", "column": { "$ref": "select_holdings.output#market_cap" } }, "input": [{ "$ref": "select_holdings" }] } ``` > **Injecting the run time** > > `{"$ref": "context.request_context.time"}` resolves to the timestamp of the current execution. Use it > instead of hard-coding a date, so the same graph works for a live run and for every step of a backtest. ## Structural rules Validation enforces three things about a workflow's shape. They are not style advice — a graph that breaks them is rejected. 1. Every flow starts with a **trigger** operation. That is what gives the run a schedule and a timestamp. 2. The workflow must include **`create_index_holdings`**. Without holdings there is nothing to value. 3. Every flow must **persist a result**. A graph that computes and discards is not a valid index. ## The three artifacts An index run produces three distinct things, and keeping them apart matters because they answer different questions and are stored in different tables. | Artifact | What it is | Produced by | Read from | | --- | --- | --- | --- | | **Universe** | The set of securities eligible for inclusion, before any weighting decision | `create_index_universe`, read back with `get_index_universe` | `index-universes` | | **Weighting** | Target proportions across the selected securities — the intent | `create_index_weighting` | `index-weightings` | | **Holdings** | Actual share counts plus the divisor — the realisation of that intent at a point in time | `create_index_holdings` | `index-holdings` | The distinction between weighting and holdings is the one that trips people up. A weighting says "3.2% of the index should be this security". Holdings say "the index owns 41,207 shares of it, and the divisor is 18,431.77". Between rebalances the weights drift with prices while the share counts stay fixed; corporate actions change the share counts and the divisor without changing the intended weights. Weighting types include `equal` and `proportional` — the latter takes a column to weight on, typically market capitalisation, referenced with the `.output#` form above. ## Values From holdings the engine calculates the index **value**: the level series, one point per session, written to `index-values-eod`. Two series are maintained — **PR** (price return) and **TR** (total return, which reinvests dividends). Quoting one when a counterparty expects the other is a silent, material error, so be explicit about which you are publishing. Corporate actions land in `index-corporate-actions` and feed back into holdings and the divisor, which is how the level stays continuous across a split or a special dividend. ## Triggers and executions A trigger operation defines when a flow runs. Because index work is tied to market sessions rather than wall clock time, triggers understand exchange calendars: `alignment_enabled`, `align_time`, `trading_calendar`, `trading_day` (`preceding` or `following`) and `session_time` (`close` or `open`). [Dates & calendars](/docs/start/dates) covers the details, including why live EOD fires fifteen minutes after the close but stamps its value at the close. One index commonly has more than one flow. The standard arrangement is a **two-flow pattern**: an annual reconstitution flow rebuilds the universe, and a quarterly rebalance flow reads that stored universe and refreshes weights and holdings from it. Splitting them means the expensive selection logic runs once a year, and the quarterly run cannot accidentally change index membership. ## Deliveries A **delivery** is a separate scheduled graph, with its own id prefix `dlv_`, that takes index artifacts and ships them outward — an email attachment, a webhook POST, an SFTP drop. It is deliberately not part of the index workflow: recalculating an index and distributing its output fail for different reasons and want different retry behaviour. ## Datasets A **dataset** (`dst_`) is data you bring yourself — an ESG score file, a proprietary signal, a custom universe list — uploaded and then referenced by dataset operations inside a workflow. Before writing a filter against one, inspect it: column names and the actual set of values in a column are things to discover, not assume. ## Identifiers Prefixes tell you what an id refers to and which plane of the API it belongs to. | Prefix | Object | Example | | --- | --- | --- | | `idx_` | Index workflow | `idx_bh7fgXWJMaa3` | | `dlv_` | Delivery | `dlv_…` | | `dst_` | Dataset | `dst_…` | | `bkt_` | Backtest | `bkt_…` | | `trg_` | Trigger | `trg_…` | | `fil_` | File | `fil_…` | ## Where the data lives `GET /query` is the read path for workflow indices. It takes a table name and a partition key, and returns `{items, count, cursor}`. These are the queryable tables. | Table | Sort key | Holds | | --- | --- | --- | | `index-parameters` | — | The workflow definition itself | | `index-holdings` | `time` | Share counts and divisor per rebalance | | `index-values-eod` | `time` | The PR and TR level series | | `index-weightings` | `time` | Target proportions per rebalance | | `index-universes` | `time` | Eligible securities per reconstitution | | `index-corporate-actions` | `time` | Splits, dividends and other actions applied | | `deliveries` | — | Delivery definitions | - [Build and backtest an index](/docs/guides/build-and-backtest) — Put the vocabulary to work on a real graph. - [Pull live index data](/docs/guides/pull-index-data) — Read every artifact for a running index. - [Operation catalog](/docs/agents/operations) — All 68 operations with their parameters. - [Dates & calendars](/docs/start/dates) — Sessions, alignment and the two datetime formats. --- # Base URLs > Hosts, stages and the MCP endpoint. There is one public host. Everything — REST, the MCP server, the operation catalog — hangs off `https://api.indexone.io`, served from AWS `eu-west-1`. | Surface | URL | | --- | --- | | REST API | `https://api.indexone.io` | | MCP server | `https://api.indexone.io/mcp` | | WebSocket (execution streaming) | `wss://846dfj97pc.execute-api.eu-west-1.amazonaws.com/prod/` | | Cognito hosted UI | `https://auth.indexone.io` | | Region | `eu-west-1` | ## No stage in the path The API is fronted by a custom domain, and the custom domain maps the stage away. Paths start at the resource: `https://api.indexone.io/schema`, `https://api.indexone.io/query`, `https://api.indexone.io/workflows`. > **The `/prod` trap** > > Raw API Gateway URLs include the stage, so `/prod/…` looks plausible and gets copied into clients by > habit. It is not part of the public path. `https://api.indexone.io/prod/schema` returns > `{"error": "Missing Authentication Token"}` — API Gateway's generic response for a route that does not exist, > which reads like an auth problem and sends people to the wrong page of these docs. If you get that message, > check the path before you check your credentials. ```bash # correct curl https://api.indexone.io/schema # wrong — 'Missing Authentication Token', and nothing to do with auth curl https://api.indexone.io/prod/schema ``` ## There is no public sandbox Index One runs internal stages, but none of them is exposed to external developers. There is no `sandbox.` host, no test-mode API key, and no separate set of credentials that writes to throwaway storage. Anything you create against `api.indexone.io` is real: real workflows, real deliveries, real data. That is a genuine constraint rather than an oversight to work around, so plan for it. ## Developing safely against production - Use `POST /simulate` and `POST /execute` while iterating — they run a graph and return results without persisting an index. Both are public, so you can develop a workflow before you even hold a key. - Backtests never create a live index. Only an explicit deploy does that, and deploy runs behind a full backtest and an empty-index safety gate. - Create a dedicated team for experiments. Team isolation is the closest thing to a sandbox available, and it keeps test artifacts out of the list your colleagues see. - Use a separate API key for development so its throttle and its blast radius are independent of production traffic. ## Public endpoints One endpoint needs no credentials at all, which makes it useful for smoke-testing connectivity from a new network or CI runner. | Endpoint | Purpose | | --- | --- | | `GET /schema` | The operation catalog — identical for every caller, no team data | The MCP discovery endpoints under `/.well-known/` and `POST /oauth/register` are also unauthenticated, because the OAuth specifications require them to be reachable before a client holds any credential. They describe how to authenticate; they expose nothing. > **`/execute`, `/simulate` and `/trigger` now need a key** > > Until 21 July 2026 these three ran without any credential. They execute workflows and drive the scheduler, > so they now require `x-api-key` and count against your usage plan. `GET /schema` stays public — it is a > catalog, not a compute path. > **Public does not mean unlimited** > > `GET /schema` bypasses the API key check and therefore the per-key usage plan — but it still sits behind > the same 29-second gateway integration timeout as everything else. See [Rate limits & > timeouts](/docs/start/rate-limits). ## The MCP endpoint `https://api.indexone.io/mcp` speaks streamable HTTP and is stateless — there is no session to establish and no long-lived connection to keep alive. It accepts an `x-api-key` header, an `Authorization: Bearer ` header, or Cognito OAuth 2.1. Its throttle is per team rather than per key. [Connect an agent](/docs/mcp/connect) has the client configuration. ## The WebSocket Long executions cannot finish inside the gateway's 29-second window, so the worker streams instead. Connect to `wss://846dfj97pc.execute-api.eu-west-1.amazonaws.com/prod/`, pass the connection identifier into `/execute` or `/simulate` as `websocket_id` or `connection_id`, and the worker pushes progress frames as it goes. This is the one place the `prod` stage does appear in a URL, because it is a raw API Gateway endpoint with no custom domain in front of it. - [Authentication](/docs/start/authentication) — Which of these surfaces wants which credential. - [Rate limits & timeouts](/docs/start/rate-limits) — The 29-second ceiling and the streaming path. - [Errors](/docs/start/errors) — Telling a bad path from a bad credential. --- # Errors > Every error shape the API returns, and how to tell them apart. Every error the API returns has the same JSON body — an `error` string — and a meaningful HTTP status code. Read the status for the category and `error` for the human-readable detail. There is nothing else to learn: the same shape comes back from the Lambda handlers, the FastAPI worker, and the API gateway itself. ## The error shape ```json { "error": "workflow not found" } ``` `error` is always a string. Two optional fields appear when they add something the status code cannot: | Field | When | What it is | | --- | --- | --- | | `error` | Always | Human-readable message. Safe to show a user or log. | | `code` | Some 401/402s | A stable machine string to branch on — e.g. the Cognito exception on a sign-in failure, or `payment_method_required`. | | `issues` | Validation 422s | An array of structured problems (`severity`, `code`, `message`, `path`, `operation_id`) when a workflow fails validation. | A sign-in failure carries a `code` so you can tell a wrong password from an unconfirmed account without parsing the message: ```json { "error": "Incorrect username or password.", "code": "NotAuthorizedException" } ``` A workflow that fails validation (on `POST /workflows`, `POST /simulate` or `POST /validate`) returns a summary in `error` and the detail in `issues`: ```json { "error": "Fix these validation issues and retry.", "issues": [ { "severity": "error", "code": "missing_input", "message": "operation 'filter_dataframes' has no wired input", "path": "operations[2].inputs", "operation_id": "filter_dataframes" } ] } ``` ## Status codes | Status | Means | What to do | | --- | --- | --- | | `400` | Malformed request — bad JSON, missing a required field | Fix the request; do not retry it unchanged | | `401` | Missing or invalid credentials — a rejected Cognito token, sign-in, or refresh | Re-authenticate. Send the token raw — a `Bearer ` prefix also 401s | | `402` | The team needs a valid payment method | Add a payment method, then retry (see `code: payment_method_required`) | | `403` | Authenticated, but not allowed — missing/invalid API key, or not a member/owner of the resource | Check the key and that the caller owns the resource | | `404` | Unknown resource id | Check the id and its prefix (`idx_`, `dlv_`, `dst_`, …) | | `409` | The resource already exists — e.g. signing up an email already registered | Use the existing resource instead of creating it | | `422` | Well-formed but failed validation | Read `issues`, fix each, and retry | | `429` | Rate limited | Honour `Retry-After` and back off | | `5xx` | Server or worker failure | Retry with backoff; if it reproduces, the request itself is likely the cause | > **Unknown routes still say "Missing Authentication Token"** > > A request to a path or method that does not exist is rejected by the gateway as > `{"error": "Missing Authentication Token"}` — usually a stray `/prod` in the URL or a typo, not a credentials > problem. Fix the URL, not the key. See [Base URLs](/docs/start/environments). ## Handling errors Because there is one shape, one helper covers everything: on a non-2xx, read `error`. All 4XX/5XX responses — including a gateway key rejection — carry CORS headers, so a browser `fetch` can read the status and body rather than failing with an opaque network error. **Python** ```python def error_message(response): """Return the error string, or None if the request succeeded.""" if response.ok: return None try: return response.json().get("error") or f"HTTP {response.status_code}" except ValueError: return f"HTTP {response.status_code}" ``` **JavaScript** ```javascript async function errorMessage(response) { if (response.ok) return null; try { const body = await response.json(); return body.error ?? `HTTP ${response.status}`; } catch { return `HTTP ${response.status}`; } } ``` **TypeScript** ```typescript async function errorMessage(response: Response): Promise { if (response.ok) return null; try { const body = (await response.json()) as { error?: string }; return body.error ?? `HTTP ${response.status}`; } catch { return `HTTP ${response.status}`; } } ``` > **Log the raw body** > > Keep the raw response body in your logs alongside your normalised message. On a validation failure the > `issues` array is the whole diagnosis, and `code` on a sign-in failure tells you exactly which credential > check failed. - [Authentication](/docs/start/authentication) — The credential mistakes behind most 401s and 403s. - [Rate limits & timeouts](/docs/start/rate-limits) — Where 429s and timeout-shaped 500s come from. - [Base URLs](/docs/start/environments) — The `/prod` path trap behind "Missing Authentication Token". --- # Rate limits & timeouts > Throttles, the 29-second ceiling, and streaming long runs. Two limits shape how you call this API: a request rate, and a hard 29-second ceiling on how long any single HTTP request can take. The second one matters more than the first, because index calculation is genuinely slow work and the ceiling is not negotiable. ## Request rate | Surface | Limit | Scope | Over limit | | --- | --- | --- | --- | | REST API | ~60 requests/min | Per API key (gateway usage plan) | Gateway throttle response | | MCP server | 60 requests/min | Per team | `429` with `Retry-After` | The REST throttle is a usage plan attached to the API key, so it only applies to requests the gateway validated a key for. Because it is per key, one busy batch job can starve everything else sharing that key — issue separate keys for separate workloads. The MCP server counts per *team*, not per key, and uses a fixed 60-second window rather than a rolling one. The window resets on a boundary, so a burst that exhausts the budget early leaves you blocked for the remainder of that minute and then fully replenished. ```json { "error": "rate_limited", "message": "Rate limit exceeded" } ``` > **Honour `Retry-After`** > > The MCP 429 carries a `Retry-After` header. Sleeping for that value is strictly better than a blind > exponential backoff, because the fixed window means the header tells you exactly when the budget resets rather than > approximately. ## The 29-second ceiling API Gateway's integration timeout is **29 seconds**. It is an AWS-side maximum, not a setting anyone can raise, and every route that does its work inline inherits it — `/execute`, `/chat` and `/mcp` among them. If the work behind such a request has not finished by then, the connection is cut regardless of what the worker is doing. The worker usually carries on, which means a timed-out request is not proof the work failed. > **A timeout is not a failure signal** > > Do not treat a cut connection as "the run did not happen" and retry blindly — you may end up with the same > expensive computation running twice. Where an endpoint gives you an id to poll, poll it. This ceiling explains a design decision you will meet elsewhere and might otherwise find arbitrary: the long-running backtest and deploy paths never hold a response open on the run. They validate, launch the job, and return a `bkt_...` id immediately, so the call always answers cleanly instead of being severed mid-response. This is true on both surfaces — REST `POST /simulate` (poll `GET /backtests/{backtest_id}`) and `POST /workflows`, and the MCP `run_backtest` / `deploy_index` tools (poll `get_backtest`) — because they share one engine. ## The polling pattern 1. **Launch the run** REST `POST /simulate` (or the MCP `run_backtest` tool) validates, starts the work in the background and returns an id immediately — a `bkt_...` for a backtest, plus an `idx_...` for a deploy via `POST /workflows` / `deploy_index`. The launch never holds the response open on the computation, so it cannot be severed mid-run. 2. **Poll the id** `GET /backtests/{backtest_id}` over REST — or the MCP `get_backtest` tool — reads the persisted result. It absorbs the in-flight window server-side, waiting a few seconds and re-checking rather than bouncing an instant "running" back, so a poll that arrives just before the result lands still resolves in that one call and back-to-back polling stays cheap. 3. **Read the outcome** Status settles on `completed` or `failed`; a completed deploy also surfaces its `deployed_index_id` here. Keep the interval well clear of the 60/min throttle — a few seconds between polls is plenty for work measured in minutes. ## Streaming over WebSocket Polling tells you when something finished. For long runs where you also want progress as it happens, the worker streams alongside the poll. Connect to `wss://846dfj97pc.execute-api.eu-west-1.amazonaws.com/prod/`, then pass your connection identifier into `/execute` or `/simulate` as `websocket_id` or `connection_id`. The HTTP request returns promptly with the `backtest_id` and the worker pushes frames down the socket as the run progresses. | Frame | Meaning | | --- | --- | | `execution_start` | The worker has picked up the run | | `execution_complete` | The run finished; results are attached | | `execution_error` | The run failed | | `backtest_error` | A backtest-specific failure | > **Two ways past the ceiling, for two jobs** > > Polling a `backtest_id` and streaming a socket both escape the 29-second ceiling; they answer different > questions, and both are available over plain REST and MCP alike. The poll (`GET /backtests/{backtest_id}` / > `get_backtest`) tells you when a run *finished* and hands you the persisted result — durable and stateless, the > path to reach for by default. The socket shows *progress as it happens*, which a live UI wants; it is an addition > to the poll, not a replacement. Use the poll to retrieve, the socket to watch. ## Response size caps Long histories are large. `get_index_values` on the MCP server caps a series at **5000 points** and evenly downsamples anything longer, always keeping the latest point so the most recent value is never dropped. The response reports what happened: `{count, returned, downsampled}`. `count` is the true number of points in the range, `returned` is how many you got, and `downsampled` tells you whether the series you are holding is a thinned view. Check that flag before computing anything path-dependent — drawdown and realised volatility both change meaningfully on a downsampled series. For a complete series, narrow the date range and page through it, or read `index-values-eod` through `GET /query` with its cursor. ## Pagination Only `GET /query` paginates with a cursor. The cursor is an opaque base64 string; pass it back to get the next page, and treat `cursor: null` as the end of the data. Do not parse or construct one. The list endpoints — `GET /workflows` and `GET /deliveries/{id}` — are unpaginated full scans. They return everything your team has. That is fine at current sizes and worth knowing about before you build a UI that calls one on every keystroke. ## Retry guidance - Retry `429` and `500`. Do not retry `400`, `401`, `403` or `404` — nothing about the request will have changed. - On `429`, sleep for `Retry-After` when it is present rather than guessing. - Otherwise use exponential backoff with jitter, starting around one second, and cap the total attempts. Synchronised retries from several workers turn one throttle into a sustained one. - Never retry a timed-out long run without first checking whether it completed — use the returned id, not a fresh request. - Keep polling intervals inside the 60/min budget; polls and real work share the same throttle. - Cache the operation catalog from `GET /schema`. It changes rarely and there is no reason to re-fetch it per request. - [Errors](/docs/start/errors) — Reading a 429 and everything else the API returns. - [Base URLs](/docs/start/environments) — Hosts, the MCP endpoint and the WebSocket. - [Build and backtest an index](/docs/guides/build-and-backtest) — The long-running path in practice. --- # Dates & calendars > The datetime format, timezones and exchange calendars. Every date and time the API accepts or returns is a plain string in one of two forms. There is nothing else to learn — the same standard holds across the REST API and the MCP server, on input and on output. ## The one standard | When | Format | Example | | --- | --- | --- | | A date is enough | `YYYY-MM-DD` | `2020-01-03` | | A time matters too | `YYYY-MM-DD HH:MM:SS` | `2020-01-03 16:00:00` | Which form you get for a given field depends only on whether the time-of-day is meaningful for that field — an API key's `created_at` is a date, an index value's `time` is a full timestamp. Both forms are **UTC**. The string never carries a timezone designator: there is no `T` separator, no trailing `Z`, and no `+00:00` offset. The date and the time are separated by a single **space**. > **Input is lenient; output is canonical** > > On the way out you always get exactly the two forms above. On the way in the API is forgiving — it also > accepts an ISO `T` separator and a trailing `Z` (`2020-01-03T00:00:00Z`) and normalises them for you — but > emit the plain space-separated form and you never have to think about it. ## The one parsing gotcha Because a timestamp carries no timezone designator, a parser that assumes a bare timestamp is *local* time will silently shift every point in your series by your machine's offset. Treat the string as UTC when you parse it. > **`new Date("2020-01-03 16:00:00")` is parsed as local time** > > Most JavaScript engines read a bare, designator-less timestamp as local time, not UTC. Rewrite it to an > explicit-UTC ISO string first — swap the space for `T` and append a `Z` — before handing it to `Date`. In > Python, `datetime.fromisoformat` parses both forms directly; attach `tzinfo=timezone.utc` yourself. **Python** ```python from datetime import datetime, timezone def parse_time(value: str) -> datetime: """Parse an Index One date or datetime as an aware UTC datetime.""" # fromisoformat handles both "YYYY-MM-DD" and "YYYY-MM-DD HH:MM:SS". parsed = datetime.fromisoformat(value.strip()) return parsed.replace(tzinfo=timezone.utc) ``` **JavaScript** ```javascript function parseTime(value) { // Rewrite to explicit-UTC ISO so the runtime never applies a local offset. const text = String(value).trim(); const iso = text.length === 10 ? `${text}T00:00:00Z` : `${text.replace(" ", "T")}Z`; return new Date(iso); } ``` **TypeScript** ```typescript function parseTime(value: string): Date { const text = value.trim(); const iso = text.length === 10 ? `${text}T00:00:00Z` : `${text.replace(" ", "T")}Z`; return new Date(iso); } ``` ## Timezones live on the index, not the timestamp Serialised timestamps are UTC. The *local* context of an index — the timezone its close happens in — is configuration, held in `index_parameters`. | Field | Format | Example | | --- | --- | --- | | `timezone` | IANA timezone name | `US/Eastern` | | `eod_time` | `HH:MM:SS`, local to `timezone` | `16:00:00` | | `exchange_calendar` | MIC code | `XNYS`, `XLON` | `eod_time` is a wall-clock time in the index's own timezone, which means the corresponding UTC instant moves twice a year with daylight saving. An index closing at `16:00:00` `US/Eastern` closes at 20:00 UTC while daylight time is in force and 21:00 UTC the rest of the year. Never precompute a fixed UTC close time and cache it across a DST boundary. ## Exchange calendars An `exchange_calendar` is a MIC code identifying a real trading calendar — `XNYS` for the New York Stock Exchange, `XLON` for London. It defines which days are sessions, which are holidays, and when each session opens and closes, including half days. This is what makes "the previous trading day" a well-defined idea. Without a calendar, a schedule can only speak in calendar days and will happily fire on Christmas Day or the Monday of a bank holiday. ## Trigger alignment Trigger operations snap a schedule to the session grid of a calendar. Five parameters control it. | Parameter | Meaning | | --- | --- | | `alignment_enabled` | Turn calendar alignment on — without it, the schedule is plain calendar time | | `align_time` | The reference time the alignment is computed against | | `trading_calendar` | Which MIC calendar to align to | | `trading_day` | `preceding` or `following` — which side to snap to when the target is not a session | | `session_time` | `close` or `open` — which end of the session to use | A typical daily configuration aligns to the **preceding** trading day at **close** on `XNYS`. That gives a run a stable, unambiguous "as of" point: the last completed session, whatever holidays intervened. > **Alignment is evaluated in local time** > > Session lookups resolve against the calendar's *local* date, not the UTC date of the fire time. For a > calendar whose local date differs from the UTC date at the moment the schedule fires — a London index around > midnight during BST, for example — resolving on the UTC date picks the wrong session and the run lands one session > early. If you are configuring a trigger near a local midnight, verify which session it actually selects. ## Close + 15, stamped at the close Live EOD does not run at the closing bell. It fires at **close + 15 minutes**, because closing quotes take time to settle and a value computed the instant the bell rings is computed against prices that are still moving. The value it writes is nevertheless **stamped at the close time**, not at the time the calculation ran. So an index closing at 16:00:00 local produces a point timestamped 16:00:00, written around 16:15:00 local. > **What this means for consumers** > > A point's timestamp is the session it belongs to, not the moment it became available. Polling for today's > close at exactly the close time will find nothing — allow the settle window. And do not infer freshness from the > timestamp: it deliberately understates when the data was written. - [Core concepts](/docs/start/concepts) — Triggers, executions and the two-flow pattern. - [Pull live index data](/docs/guides/pull-index-data) — Reading a value series with the right time bounds. - [Corporate actions](/docs/guides/corporate-actions) — What changes on a split, and when. --- # Symbols & identifiers > Venue-suffixed symbols, the exchange suffix table, and composite FIGIs. Every instrument on the platform has a **symbol** and an **id**. The id is the security's **composite FIGI** (`BBG000B9XRY4`) and it is what index operations actually join on. The symbol is the human-readable form you write in a file, a filter or a query — and the one this page is about. A platform symbol is always **venue-suffixed**: the ticker, a dot, and the venue's Bloomberg composite exchange code. ```text AAPL.US Apple, US listing VOD.LN Vodafone, London 7203.JP Toyota, Tokyo BRK-B.US Berkshire Hathaway class B USD.FX US dollar cash ``` Two conventions inside the ticker itself: a **share class** is separated with a hyphen (`BRK-B`, `ELUX-B.SS`), and **cash** is the ISO currency code with a `.FX` suffix (`USD.FX`, `EUR.FX`). ## What resolves Wherever you supply a symbol — a dataset column, a `map_identifiers` step, a query — resolution walks a ladder and takes the first hit: the platform symbol, then common vendor spellings of it, then the id itself. So more than the canonical form will work, and you never need to pre-convert your tickers by hand. | You write | Resolves? | Notes | | --- | --- | --- | | `AAPL.US` | Yes | The canonical form. Unambiguous — always prefer it. | | `AAPL` | Yes | A bare ticker resolves for US listings. Ambiguous elsewhere, so bare non-US tickers should carry their suffix. | | `VOD.L`, `7203.T` | Usually | Common vendor spellings are accepted as aliases, but they are not the platform form. | | `BBG000B9XRY4` | Yes | A composite FIGI — the id itself, so no lookup happens at all. | > **Unmatched symbols do not fail — they disappear** > > A symbol that resolves to nothing passes through without an id and is dropped at the weighting step. The > index is then built from fewer names than you supplied, with no error anywhere. Count the rows before and after > identifier mapping whenever you load a list you have not used before. ## The id Index operations join on the id, never on the symbol — a symbol is resolved to an id once, at the identifier-mapping step, and everything downstream works in ids. An id is the security's **composite FIGI**: the FIGI that identifies a security across every venue of a single market, rather than the **share-class FIGI** above it (which spans markets and currencies) or the **venue FIGI** below it (a single order book). Composite is the right level for an index — a holding should not change identity because a trade printed on an alternative venue rather than the primary exchange. Over-the-counter lines are the one exception: they are keyed on their venue FIGI. ## Exchange suffixes The suffix is the venue's **Bloomberg composite exchange code**, not the vendor code you may have seen elsewhere. Currency is the venue's default quote currency, which is what an unconverted price is denominated in. > **Three suffixes that catch people out** > > `.CH` is **China**, not Switzerland — Switzerland is `.SW`. `.IT` is **Israel**, not Italy — Italy is > `.IM`. `.ID` is **Ireland**, not Indonesia — Indonesia is `.IJ`. ### Americas | Suffix | Market | Country | Currency | | --- | --- | --- | --- | | `.US` | NASDAQ, NYSE, NYSE American, Cboe, OTC | United States | `USD` | | `.CN` | Toronto (TSX), TSX Venture, Cboe Canada, CSE | Canada | `CAD` | | `.MM` | Bolsa Mexicana de Valores | Mexico | `MXN` | | `.BZ` | B3 | Brazil | `BRL` | | `.AR` | Bolsa de Comercio de Buenos Aires | Argentina | `ARS` | | `.CI` | Bolsa de Santiago | Chile | `CLP` | | `.CB` | Bolsa de Valores de Colombia | Colombia | `COP` | ### Europe | Suffix | Market | Country | Currency | | --- | --- | --- | --- | | `.LN` | London Stock Exchange | United Kingdom | `GBp` | | `.GR` | Xetra | Germany | `EUR` | | `.FP` | Euronext Paris | France | `EUR` | | `.NA` | Euronext Amsterdam | Netherlands | `EUR` | | `.BB` | Euronext Brussels | Belgium | `EUR` | | `.PL` | Euronext Lisbon | Portugal | `EUR` | | `.ID` | Euronext Dublin | Ireland | `EUR` | | `.IM` | Borsa Italiana | Italy | `EUR` | | `.SM` | BME (Madrid) | Spain | `EUR` | | `.SW` | SIX Swiss Exchange | Switzerland | `CHF` | | `.AV` | Wiener Börse | Austria | `EUR` | | `.SS` | Nasdaq Stockholm | Sweden | `SEK` | | `.DC` | Nasdaq Copenhagen | Denmark | `DKK` | | `.FH` | Nasdaq Helsinki | Finland | `EUR` | | `.NO` | Oslo Børs | Norway | `NOK` | | `.IR` | Nasdaq Iceland | Iceland | `ISK` | | `.PW` | Warsaw Stock Exchange | Poland | `PLN` | | `.CP` | Prague Stock Exchange | Czechia | `CZK` | | `.LR` | Nasdaq Riga | Latvia | `EUR` | | `.HB` | Budapest Stock Exchange | Hungary | `HUF` | | `.GA` | Athens Exchange | Greece | `EUR` | | `.TI` | Borsa İstanbul | Türkiye | `TRY` | | `.RM` | Moscow Exchange | Russia | `RUB` | ### Asia-Pacific | Suffix | Market | Country | Currency | | --- | --- | --- | --- | | `.JP` | Japan Exchange Group (Tokyo) | Japan | `JPY` | | `.HK` | Hong Kong Exchanges and Clearing | Hong Kong | `HKD` | | `.CH` | Shanghai and Shenzhen | China | `CNY` | | `.KS` | Korea Exchange (KOSPI, KOSDAQ) | South Korea | `KRW` | | `.TT` | Taiwan Stock Exchange, TPEx | Taiwan | `TWD` | | `.IN` | National Stock Exchange of India | India | `INR` | | `.IJ` | Indonesia Stock Exchange | Indonesia | `IDR` | | `.SP` | Singapore Exchange | Singapore | `SGD` | | `.MK` | Bursa Malaysia | Malaysia | `MYR` | | `.TB` | Stock Exchange of Thailand | Thailand | `THB` | | `.AU` | ASX | Australia | `AUD` | | `.NZ` | NZX | New Zealand | `NZD` | ### Middle East & Africa | Suffix | Market | Country | Currency | | --- | --- | --- | --- | | `.IT` | Tel Aviv Stock Exchange | Israel | `ILA` | | `.AB` | Saudi Exchange (Tadawul) | Saudi Arabia | `SAR` | | `.UH` | Dubai Financial Market | United Arab Emirates | `AED` | | `.QD` | Qatar Stock Exchange | Qatar | `QAR` | | `.KK` | Boursa Kuwait | Kuwait | `KWF` | | `.SJ` | Johannesburg Stock Exchange | South Africa | `ZAc` | | `.EY` | The Egyptian Exchange | Egypt | `EGP` | > **Four currencies are quoted in minor units** > > `GBp` (pence), `ILA` (agorot) and `ZAc` (cents) are a hundredth of `GBP`, `ILS` and `ZAR`; > `KWF` (fils) is a thousandth of `KWD`. The platform converts them for you, but a raw price pulled from one of > those venues is in the minor unit — a London price of `8250` is £82.50, not £8,250. ## Where symbols show up | Place | What to know | | --- | --- | | A dataset column | Name it `symbol` and identifier mapping picks it up with no configuration. See [Bring your own data](/docs/guides/datasets). | | `map_identifiers` | The operation that turns symbols into ids. All four sides are configurable, so a file that keys on something other than a ticker can be matched on that column instead. | | Filters on the securities reference | The reference stores the suffixed form, so a bare `AAPL` filter matches **nothing**. Filter on `AAPL.US`, or map first. | | Query results and deliveries | Output is keyed by FIGI by default. Pass `map_symbols=true` on a query, or `category: "symbol"` on a delivery, to get symbols back instead. | ## Where to go next - [Build a basket index (no code)](/docs/guides/basket-index) — Put a list of these symbols in a file and index it. - [Bring your own data](/docs/guides/datasets) — Datasets, and the identifier mapping step in full. - [Dates & calendars](/docs/start/dates) — The other set of conventions worth reading once. - [Core concepts](/docs/start/concepts) — Universe, weighting, holdings, value. --- # Build a basket index (no code) > Upload a file of holdings and weights, backtest it in the browser, download the results. A basket index is the simplest index there is: you decide what it holds and how much of each, and the platform does everything else — pricing every holding at every session close, adjusting for splits and dividends, and turning that into a value series you can chart, compare against a benchmark, and download. This is the browser version of that job. You will not write any code and you will not call the API: you upload a spreadsheet, press a button, and download a CSV. Budget about twenty minutes the first time. If you would rather drive the same thing from a program, [Bring your own data](/docs/guides/datasets) is the API version of this page, and [Build and backtest an index](/docs/guides/build-and-backtest) covers the mechanics underneath. ## What you will end up with - A **backtest** — your basket, valued at every trading session between your start date and today. - A **chart** of that value series, with drawdowns, volatility and the usual return statistics. - The **holdings** at each rebalance, priced and share-counted rather than just weighted. - Three **CSV downloads**: the value series, the current holdings, and every historical holdings snapshot. ## Before you start - An Index One account. Registration is self-serve — see step 1. - A list of the securities you want to hold. Tickers are fine. - Optionally, a weight for each one, and optionally a date for each rebalance. - A spreadsheet program that can save a `.csv` file — Excel, Numbers and Google Sheets all do. ## Step 1 — Create an account 1. **Register** Go to [indexone.io/register](https://indexone.io/register) and sign up. Use your university or work email address if you were asked to — it is how your account gets attached to the right team. 2. **Confirm your email** A confirmation code arrives by email. Enter it to activate the account. 3. **Log in** Sign in at [indexone.io/login](https://indexone.io/login). You land on the dashboard, with the navigation rail down the left-hand side. ## Step 2 — Open the Index Builder In the left-hand rail, click **Index Studio**. The page is titled **Index Builder** and has two halves. The **canvas** on top is where the index is defined. Each box is one step — read the file, look up the securities, apply the weights, record the holdings — wired left to right. You will change two of those boxes and leave the rest alone. The **results** below fill in once you run a backtest: chart, statistics, holdings and downloads. > **No Index Studio in the rail?** > > The Studio is enabled per team. If **Index Studio** and **Datasets** are missing from the navigation, your > team has not been switched on for it yet — email [contact@indexone.io](mailto:contact@indexone.io) with your > account address and ask, mentioning your course or group if you are on a student programme. > **Everything on the canvas can explain itself** > > The **Describe workflow** button in the canvas toolbar puts a one-line caption on each box saying what > that step does. Turn it on the first time you load a template — it is the fastest way to understand what you are > looking at. ## Step 3 — Choose a template Open the round **+** button in the canvas toolbar, choose **Load Example**, then the **Custom Dataset Index** card. It offers six starting points; the first four are the basket formats, and they differ only in what your file contains. | Template | Your file has | What the index does | | --- | --- | --- | | **Symbol Only** | `symbol` | Holds that list, rebalancing once a year, weighted by market capitalisation. | | **Symbol + Weight** | `symbol`, `weight` | Holds that list at your weights, rebalancing once a year. | | **Date + Symbol** | `date`, `symbol` | Rebalances on every date in your file, weighted by market capitalisation on that date. | | **Date + Symbol + Weight** | `date`, `symbol`, `weight` | Rebalances on every date in your file, at the weights you gave for that date. | **Date + Symbol + Weight** is the full basket calculation — your constituents and your weights, at each point in time — and it is the one this guide follows. The other three are the same thing with a column left out. Each card has a download icon in its top-right corner that gives you a **sample CSV** in exactly the right shape. Download it before you click the card: opening it in Excel next to your own data is the quickest way to get the format right. Clicking the card loads the template onto the canvas. ## Step 4 — Prepare your file One row per holding per rebalance date. Three columns, named exactly `date`, `symbol` and `weight`. ```text date,symbol,weight 2020-01-02,AAPL.US,0.25 2020-01-02,MSFT.US,0.25 2020-01-02,AMZN.US,0.25 2020-01-02,GOOGL.US,0.25 2021-01-04,AAPL.US,0.40 2021-01-04,MSFT.US,0.30 2021-01-04,NVDA.US,0.30 ``` That file says: from 2 January 2020 hold four names at a quarter each; from 4 January 2021 hold three names at 40/30/30. Between those dates the index simply runs — the weights drift with the prices, which is what an index is supposed to do. You need a row only for the days you rebalance, not for every trading day. | Rule | Why | | --- | --- | | Save as **`.csv`** | In Excel: *File → Save As → CSV UTF-8*. An `.xlsx` will not upload. | | **Keep the header row** | The first row must be the column names. This is the opposite of the old platform, which wanted no headers — the new one reads your columns by name. | | Dates as **`YYYY-MM-DD`** | With leading zeros: `2020-01-01`, not `2020-1-1` and not `01/01/2020`. Watch for Excel silently reformatting a date column. | | Weights as **decimals** | 2% is `0.02` — not `2`, and not `2%`. | | **Weights add up to 1** per date | They are used exactly as written. See the warning below. | | One row per holding | Not one column per holding. A wide matrix will not upload; stack it into rows. | | Symbols **venue-suffixed** | `AAPL.US`, `VOD.LN`, `7203.JP` — see [Symbols & identifiers](/docs/start/symbols). | > **Your weights are used exactly as you write them** > > The platform does not rescale a custom basket. If your weights for a date add up to 0.9, the index is 90% > invested and 10% in nothing, and it will underperform for reasons that have nothing to do with your idea. Check > the total for every date before you upload — a `SUMIF` per date in Excel takes a second and saves an afternoon. > **Tickers: add the exchange suffix** > > A platform symbol is the ticker, a dot, and the exchange code: `AAPL.US`, `VOD.LN` for London, > `7203.JP` for Tokyo. **US listings take `.US`** — a bare `AAPL` still resolves, but the suffixed form is > the one to write. A share class uses a hyphen (`BRK-B.US`). The full list of suffixes is in > [Symbols & identifiers](/docs/start/symbols) — keep it open while you build the file. ## Step 5 — Upload your file Your file becomes a **dataset**: a named table on your account that the index reads from. You upload it straight from the canvas. 1. **Open the first box on the canvas** It is the **Dataset Trigger** — the step that reads your file and decides when the index rebalances. 2. **Click + next to the dataset dropdown** The dropdown lists datasets your team already has; the **+** creates a new one. You can also do this ahead of time from **Datasets** in the left-hand rail. 3. **Drop the CSV in** The **Create Dataset** dialog takes the file by drag-and-drop or file picker, and names the dataset after the file unless you type something else. 4. **Say what the columns mean** The dialog then asks **“What do these columns mean?”** and pre-fills its best guess — `date` as a date, `symbol` as a ticker, `weight` as a number. Correct anything wrong and save. **Skip for now** is allowed and the upload still works; declaring the columns just lets the platform check your next upload for you. 5. **Check the time column** Back on the canvas, the Dataset Trigger should show `date` as its time column. That is what turns each date in your file into a rebalance. > **Fixing a file later** > > Datasets are editable — upload a corrected file over the top from the **Datasets** page and run the > backtest again. You never need to rebuild the index to change your basket. ## Step 6 — Set the index parameters Open the **+** button in the toolbar again and choose **Index Params**. These are properties of the index itself rather than of any one step. | Field | What to put | | --- | --- | | **Index Name** | Something you will recognise in a list later. *NTU Group 4 — Momentum Basket* beats *Test 3*. | | **Start Time** | When the index begins. Set it **on or just before the first date in your file** — if it starts after, the earlier rebalances in your file are never used. | | **Start Value** | The number the index starts at. `1000` is the convention and makes the chart easy to read: 1250 means up 25%. | | **Currency** | What the index is valued in. Holdings in other currencies are converted for you. | | **Exchange Calendar** | Whose trading days the index runs on: `XNYS` (New York) for a US basket, `XLON` for London, `XSES` for Singapore. | | **Timezone**, **EOD Time** | When the daily close is taken. The template already matches the calendar — leave them unless you know you need something else. | | **Dividend Policy** | Leave it as it is and you get both a price-return and a total-return series, which is what you usually want to report. | > **The most common mistake on this screen** > > A start date later than the first date in your file. The index cannot rebalance into a basket dated before > it existed, so those rows are quietly ignored and the backtest begins further along than you expected. If the > chart starts on the wrong date, come back here first. ## Step 7 — Run the backtest Open the **+** button and choose **Run Backtest**. Progress streams into the top-right of the canvas as the run works through your history, and the results section below fills in when it finishes. A few years of a small basket takes well under a minute; a long history of hundreds of names takes longer. If it fails, the message names the step that failed and what was wrong with it. The table near the end of this page covers the ones you are most likely to hit. ## Step 8 — Read the results Scroll down. You get the value series charted from your start date to today, the standard return and risk statistics, and the holdings behind it — including what the index held at each past rebalance, not just today. You can add a benchmark to the chart: search for an index or an ETF and it is drawn on the same axes and included in the statistics. That comparison is usually the point of the exercise, so do it before you start writing anything up. > **Check the holdings before you trust the chart** > > Open the holdings for the first rebalance and count them. If you uploaded 20 names and see 18, two tickers > matched nothing and their weight went nowhere. Fix those tickers and run again — a basket that is quietly 92% > invested produces a chart that looks fine and is wrong. ## Step 9 — Download the data Click **Download Data** in the results section. Everything comes out as CSV, one row per record, oldest first — ready to open in Excel or read into Python or R. | Download | Columns | Use it for | | --- | --- | --- | | **Values** | `date`, `id`, `value` | The index level per day — the series you chart and compute returns from. Any benchmarks you added are included. | | **Current Holdings** | `date`, `symbol`, `weight` | What the index holds now. | | **All Historical Holdings** | `date`, `symbol`, `weight` | Every rebalance stacked into one table: turnover, weight drift, what changed and when. | Files are named after the index, so *NTU Group 4 — Momentum Basket - Values.csv* arrives ready to hand in without renaming. ## Optional — make it live A backtest is a one-off simulation. If you want the index to keep calculating — updating every session close, from here on — open the **+** button and choose **Create Live Index** instead. It gets an id, appears under **Indices**, and can be read through the API, charted on a page, or [published in the public directory](/docs/guides/benchmarks). For coursework, a backtest is normally all you need. ## What the template actually does Four steps, and you only ever touched the first one. Worth understanding if you are writing up the methodology. _The Date + Symbol + Weight template, exactly as it loads onto the canvas._ - `dataset_trigger` — **dataset_trigger** - `map_ids` — **map_identifiers** ← `dataset_trigger` - `weights` — **create_index_weighting** ← `map_ids` - `holdings` — **create_index_holdings** ← `weights` 1. The **dataset trigger** reads your file and fires once for each distinct date in it, at that date’s market close. Your file is the rebalance schedule — there is no separate calendar to set. 2. Ticker matching turns each `symbol` into the platform’s own security id, which is what everything downstream works in. A ticker that matches nothing drops out here. 3. The **weighting** step applies the `weight` column as written. 4. The **holdings** step turns those weights into actual share counts at that date’s prices. Those share counts are what gets valued every session until the next rebalance — through splits, dividends and everything else. ```json { "name": "Dataset Index (Date + Symbol + Weight)", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "dataset_trigger", "operation": "dataset_trigger", "parameters": { "time_column": "date", "mode": "incremental", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" }, "input": [] }, { "id": "map_ids", "operation": "map_identifiers", "parameters": { "data": { "$ref": "dataset_trigger.output.data" }, "data_source_column": "symbol", "reference_source_column": "symbol", "reference_target_column": "id", "output_column": "id" }, "input": [ { "$ref": "dataset_trigger" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "map_ids.output" }, "weighting_type": "custom", "id_attribute": "id", "weight_attribute": "weight" }, "input": [ { "$ref": "map_ids" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` ## When something looks wrong | What you see | What it usually is | | --- | --- | | Fewer holdings than rows in your file | Symbols that matched nothing — often a delisted name, a typo, or a listing without its venue suffix. Their weight is simply not held. Check them against [Symbols & identifiers](/docs/start/symbols). | | The chart starts later than your first date | The index **Start Time** is after the first date in your file. | | The index barely moves, or moves far too much | Weights written as percentages rather than decimals (`5` instead of `0.05`), or weights that do not add up to 1 for each date. | | The upload is rejected | The file is not really a CSV — a renamed `.xlsx` does not count — or the header row is missing. | | Only some of your dates rebalanced | Dates not in `YYYY-MM-DD` form, or dates falling on a weekend or market holiday: those snap to the nearest trading session, which can merge two of your dates into one. | | The backtest fails on a step | The message names the step and the problem. If it mentions empty data, the step above produced no rows — usually the ticker matching. | Still stuck: **Support** in the left-hand rail, or [contact@indexone.io](mailto:contact@indexone.io). ## Where to go next - [Symbols & identifiers](/docs/start/symbols) — Every exchange suffix, and what else resolves. - [Bring your own data](/docs/guides/datasets) — The same basket, driven from the API instead of the browser. - [Build and backtest an index](/docs/guides/build-and-backtest) — Rules instead of a fixed list — screens, rankings and scheduled rebalances. - [Core concepts](/docs/start/concepts) — Universe, weighting, holdings, value — what each word means here. - [Corporate actions](/docs/guides/corporate-actions) — What happens to your basket on a split, a dividend or a delisting. - [Publish a benchmark](/docs/guides/benchmarks) — Make a live index public in the directory. - [Pull live index data](/docs/guides/pull-index-data) — Read values and holdings from a program once the index is live. --- # Build and backtest an index > Assemble an operation DAG, simulate it, and read the results. This is the guide the others build on. It takes one real index — a US momentum strategy that holds the 50 strongest twelve-month performers, weighted by their momentum score — and walks the whole path: the operation graph, what each node does and why it is wired that way, and then two ways to simulate it before anything goes live. The workflow below is not illustrative. It is a complete index payload, in the exact shape the shipped `get_example` workflows use, and it runs. _US Momentum 50 — one quarterly flow from trigger to holdings._ - `rebal_trigger` — **trigger** - `sec_ref` — **core_securities_reference** ← `rebal_trigger` - `eod_snap` — **i1_core_quote** ← `sec_ref` - `liq_filter` — **filter** ← `eod_snap` - `eod_hist` — **i1_core_eod** ← `liq_filter` - `return_12m` — **return** ← `eod_hist` - `rank_mom` — **rank** ← `return_12m` - `top_50` — **filter** ← `rank_mom` - `universe` — **create_index_universe** ← `top_50` - `weights` — **create_index_weighting** ← `top_50` - `holdings` — **create_index_holdings** ← `weights` 1. A quarterly cron trigger fires, aligned to the preceding XNYS session close. 2. core_securities_reference produces the eligible cross-section: US-domiciled common stock listed on NYSE or NASDAQ. 3. i1_core_quote prices that list as of the run time and attaches market cap. 4. filter keeps the 200 largest names — a liquidity screen, so the momentum calculation is not dominated by micro caps. 5. i1_core_eod pulls the price history for those 200 ids up to the run time. 6. return computes a 252-session trailing return per id and keeps one row per security at the rebalance date. 7. rank orders by that return, filter takes the top 50. 8. create_index_universe records what was eligible, create_index_weighting turns momentum scores into proportional weights, create_index_holdings converts weights into share counts and a divisor. ```json { "name": "US Momentum 50", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "rebal_trigger", "operation": "trigger", "parameters": { "cron": "0 0 1 1,4,7,10 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "sec_ref", "operation": "core_securities_reference", "parameters": { "filters": [ { "field": "mic", "operator": "in", "value": [ "XNYS", "XNAS" ] }, { "field": "security_type", "operator": "eq", "value": "Common Stock" }, { "field": "exchange_country", "operator": "eq", "value": "US" }, { "field": "domicile_country", "operator": "eq", "value": "US" } ], "attributes": [ "id" ] }, "input": [ { "$ref": "rebal_trigger" } ] }, { "id": "eod_snap", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "sec_ref.output#id" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "marketCap" ] }, "input": [ { "$ref": "sec_ref" } ] }, { "id": "liq_filter", "operation": "filter", "parameters": { "data": { "$ref": "eod_snap.output" }, "filters": [ { "field": "marketCap", "operator": "top_n", "value": 200 } ] }, "input": [ { "$ref": "eod_snap" } ] }, { "id": "eod_hist", "operation": "i1_core_eod", "parameters": { "filters": [ { "field": "date", "operator": "lte", "value": { "$ref": "context.request_context.time" } }, { "field": "id", "operator": "in", "value": { "$ref": "liq_filter.output#id" } } ], "attributes": [ "date", "id", "splitAdjClose" ] }, "input": [ { "$ref": "liq_filter" } ] }, { "id": "return_12m", "operation": "return", "parameters": { "data": { "$ref": "eod_hist.output" }, "price_column": "splitAdjClose", "time_column": "date", "group_by_column": "id", "window": 252, "filter_time": { "$ref": "context.request_context.time" }, "output_column_name": "momentum_12m" }, "input": [ { "$ref": "eod_hist" } ] }, { "id": "rank_mom", "operation": "rank", "parameters": { "data": { "$ref": "return_12m.output" }, "column": "momentum_12m", "output": "mom_rank", "method": "ordinal", "descending": true }, "input": [ { "$ref": "return_12m" } ] }, { "id": "top_50", "operation": "filter", "parameters": { "data": { "$ref": "rank_mom.output" }, "filters": [ { "field": "mom_rank", "operator": "lte", "value": 50 } ] }, "input": [ { "$ref": "rank_mom" } ] }, { "id": "universe", "operation": "create_index_universe", "parameters": { "universe": { "$ref": "top_50.output#id" }, "identifier": "id" }, "input": [ { "$ref": "top_50" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "top_50.output" }, "weighting_type": "proportional", "id_attribute": "id", "weight_attribute": "momentum_12m" }, "input": [ { "$ref": "top_50" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` ## The envelope Everything outside `operations` configures the index itself rather than any single node. | Field | Meaning | | --- | --- | | `name` | Display name. | | `start_time` | First point of the series. A backtest defaults its start to this. | | `start_value` | Level the index is normalised to at `start_time` — 1000 here. | | `start_divisor` | Initial divisor. The engine adjusts it from then on. | | `timezone` / `eod_time` | When a session is considered closed for valuation. | | `exchange_calendar` | MIC of the calendar that defines trading days — `XNYS`. | | `default_operations` | Machinery you get without wiring nodes: the EOD value series and corporate-action generation. | | `operations` | The DAG. | `default_operations.create_index_value_eod_default.value_series` is where the published series are declared. This index publishes two: `value_pr`, a price-return series, and `value_tr`, a total-return series with `dividend_policy: "pro_rata"`, marked `default: true`. Consumers who do not name a series get the TR one. `create_index_corporate_actions_default: {}` switches on corporate-action generation with its defaults — see [Corporate actions](/docs/guides/corporate-actions). ## Reading the graph An operation is always the same four fields: `{id, operation, parameters, input}`. `input` declares the edges; `parameters` may additionally reach into an upstream result with `{"$ref": ".output#"}`. Both are required — a `$ref` inside `parameters` whose operation is missing from `input` creates no dependency, and the value will not exist when the node runs. ### The trigger ```json { "id": "rebal_trigger", "operation": "trigger", "parameters": { "cron": "0 0 1 1,4,7,10 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } } ``` The cron says midnight on the first of January, April, July and October. On its own that is a useless timestamp for an index — those dates are frequently holidays, and midnight is not a moment at which prices exist. The alignment parameters fix both problems: `trading_calendar` names the calendar, `trading_day: "preceding"` moves the run back to the last trading day at or before the cron date, and `session_time: "close"` stamps it at that session's close. The run time that every downstream `context.request_context.time` resolves to is the aligned one. ### Selection `core_securities_reference` is the cross-section of securities the platform knows about, with row filters. The four filters here are doing real work — restricting to two MICs keeps out OTC listings, `security_type` excludes funds and depositary receipts, and requiring both `exchange_country` and `domicile_country` to be US removes foreign issuers with a US listing. Sector and industry exclusions belong here too, as a `not_in` filter, not in a later step. `attributes: ["id"]` projects the result down to the identifier column. Ask only for the columns you use; everything is carried through the rest of the graph. ### Pricing and the liquidity screen `i1_core_quote` takes a list of ids and a date and returns one row per security, shaped like an EOD row. With a past date it resolves to the last settled print at or before that date, so a backtest step that lands on a half-day or an exchange holiday still gets prices. It is the right operation for a single date; `i1_core_eod` is the right one for a range. The `filter` that follows uses `top_n` on `marketCap`. Twelve-month momentum on a universe of several thousand names is dominated by tiny illiquid stocks with enormous percentage moves, so the screen to the largest 200 is what makes the signal investable rather than a formality. ### The signal ```json { "id": "eod_hist", "operation": "i1_core_eod", "parameters": { "filters": [ { "field": "date", "operator": "lte", "value": { "$ref": "context.request_context.time" } }, { "field": "id", "operator": "in", "value": { "$ref": "liq_filter.output#id" } } ], "attributes": ["date", "id", "splitAdjClose"] }, "input": [{ "$ref": "liq_filter" }] }, { "id": "return_12m", "operation": "return", "parameters": { "data": { "$ref": "eod_hist.output" }, "price_column": "splitAdjClose", "time_column": "date", "group_by_column": "id", "window": 252, "filter_time": { "$ref": "context.request_context.time" }, "output_column_name": "momentum_12m" }, "input": [{ "$ref": "eod_hist" }] } ``` Two details matter here. The price column is `splitAdjClose`, not `close` — a raw close series contains step changes at every split that a return calculation would read as a 50% loss. And `filter_time` collapses the long history down to one row per security as of the rebalance date; without it the operation returns every row with a return column appended, and the ranking downstream would be meaningless. The date filter is `lte` the run time, never an open range. That is what keeps a backtest honest: at each historical step the graph can only see prices that existed then. > **Look-ahead comes from the data, not the engine** > > The executor replays your graph at each historical trigger date, but it cannot know which of your filters > were meant to be time-bounded. Any data operation that reads a range must bound it with > `{"$ref": "context.request_context.time"}`. A missing bound produces a backtest that looks excellent and > predicts nothing. ### Universe, weighting, holdings The last three nodes are the ones every index has. `create_index_universe` records the eligible set with `identifier: "id"`. `create_index_weighting` with `weighting_type: "proportional"` and `weight_attribute: "momentum_12m"` normalises the momentum scores into weights that sum to one — that is what "weighted by momentum" means mechanically. `create_index_holdings` turns those target proportions into share counts and a divisor. Note what `create_index_holdings` is *not* given: prices. It auto-fetches them, and when the index currency differs from a security's trading currency it converts through `fx_converter` on the way. Supplying `price_data` yourself disables that conversion, so omit it unless you have already converted. > **Other weighting types** > > `equal` is 1/N and needs no weight column. `inverse` weights by 1/value (inverse volatility); > `power` raises the value to an exponent (`power: 0.5` is the square-root-of-cap tilt); `rank` weights by > ordering alone. `custom` uses `weight_attribute` as-is rather than normalising it — that is what an > optimizer's output feeds into. `tiered` assigns fixed weights by position band. > > All of them accept `constraints`, a list where each row picks units and bounds them. UCITS 5/10/40 is > `[{scope: "security", max: 0.10}, {scope: "above", threshold: 0.05, max: 0.40}]`, or just > `rule: "ucits_5_10_40"`. A QQQ-style aggregate cap is `{scope: "top_n", n: 5, max: 0.40}`, and a sector > cap is `{scope: "group", column: "sector", max: 0.30}`. ## Backtest it `POST /simulate` launches the run over history and answers straight away with a `bkt_...` id — it no longer holds the response open on the computation. It persists nothing live and needs a team API key (it did not until 21 July 2026, so older examples you may have seen omit the header). Then poll `GET /backtests/{backtest_id}` for the result: it waits server-side for the in-flight window, so a short run is usually ready on the first poll. Add `?include_series=true` to get the value series back for charting. **cURL** ```bash # 1. Launch — returns { "backtest_id": "bkt_...", "status": "running" } curl -X POST https://api.indexone.io/simulate \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d @- <<'JSON' { "index_parameters": { "name": "US Momentum 50", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ /* the DAG above */ ] } } JSON # 2. Poll until status is "completed" (or "failed") curl "https://api.indexone.io/backtests/bkt_9Fq2LmVt41Xe?include_series=true" \ -H "x-api-key: YOUR_API_KEY" ``` **Python** ```python import json import time import requests BASE = "https://api.indexone.io" HEADERS = {"x-api-key": "YOUR_API_KEY"} with open("us-momentum-50.json") as fh: index_parameters = json.load(fh) # 1. Launch — returns immediately with a pollable id. launch = requests.post(f"{BASE}/simulate", headers=HEADERS, json={"index_parameters": index_parameters}, timeout=30) launch.raise_for_status() backtest_id = launch.json()["backtest_id"] # 2. Poll. get waits server-side for the in-flight window, so this is cheap. while True: r = requests.get(f"{BASE}/backtests/{backtest_id}", headers=HEADERS, params={"include_series": "true"}, timeout=30) r.raise_for_status() result = r.json() if result["status"] == "completed": print(json.dumps(result, indent=2)[:2000]) break if result["status"] == "failed": raise RuntimeError(result.get("error")) time.sleep(3) ``` **JavaScript** ```javascript import indexParameters from "./us-momentum-50.json" with { type: "json" }; const BASE = "https://api.indexone.io"; const headers = { "Content-Type": "application/json", "x-api-key": "YOUR_API_KEY" }; // 1. Launch — returns { backtest_id, status: "running" }. const launch = await fetch(`${BASE}/simulate`, { method: "POST", headers, body: JSON.stringify({ index_parameters: indexParameters }), }).then(r => r.json()); // 2. Poll for the result. let result; do { await new Promise(r => setTimeout(r, 3000)); result = await fetch( `${BASE}/backtests/${launch.backtest_id}?include_series=true`, { headers }, ).then(r => r.json()); } while (result.status === "running"); console.log(result); ``` **TypeScript** ```typescript import indexParameters from "./us-momentum-50.json" with { type: "json" }; const BASE = "https://api.indexone.io"; const headers = { "Content-Type": "application/json", "x-api-key": "YOUR_API_KEY" }; const launch: { backtest_id: string } = await fetch(`${BASE}/simulate`, { method: "POST", headers, body: JSON.stringify({ index_parameters: indexParameters }), }).then(r => r.json()); let result: { status: string }; do { await new Promise(r => setTimeout(r, 3000)); result = await fetch( `${BASE}/backtests/${launch.backtest_id}?include_series=true`, { headers }, ).then(r => r.json()); } while (result.status === "running"); console.log(result); ``` **Go** ```go body, _ := json.Marshal(map[string]any{"index_parameters": indexParameters}) // 1. Launch — the response body carries { "backtest_id": "bkt_...", "status": "running" }. req, _ := http.NewRequest("POST", "https://api.indexone.io/simulate", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", "YOUR_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() // 2. Then GET https://api.indexone.io/backtests/{backtest_id} on an interval // until "status" is "completed" or "failed". ``` ## The 29-second ceiling API Gateway's integration timeout is **29 seconds** — the reason `/simulate` hands back an id instead of the result. It never holds the response open on the run, so the ceiling has nothing to sever: the launch always answers in well under a second, the run continues in the background, and its result is persisted whatever happens. There are two ways to take that result, and because it is persisted a late reader still gets it: - **Poll the id.** `GET /backtests/{backtest_id}` over REST, or `get_backtest` over MCP — both read the same persisted blob and wait server-side for the in-flight window, so polling right after the launch is fine. This is the primary path. - **Stream it.** Additionally pass a `websocket_id` (or `connection_id`) alongside `index_parameters` and the worker pushes progress frames to that connection as the run computes — useful for a live UI. The frames are below. | Frame | Meaning | | --- | --- | | `execution_start` | The worker has picked the job up. | | `execution_complete` | Finished — carries the result. | | `execution_error` | The run failed before or outside the simulation itself. | | `backtest_error` | The simulation ran and failed — usually a wiring or data problem in the graph. | ```python import json import websocket # websocket-client import requests WS_URL = "wss://846dfj97pc.execute-api.eu-west-1.amazonaws.com/prod/" ws = websocket.create_connection(WS_URL) # The connection id is returned by the socket handshake; pass it on the request # so the worker knows where to push. connection_id = json.loads(ws.recv())["connection_id"] requests.post( "https://api.indexone.io/simulate", headers={"x-api-key": "YOUR_API_KEY"}, json={"index_parameters": index_parameters, "connection_id": connection_id}, timeout=30, ) while True: frame = json.loads(ws.recv()) if frame.get("type") == "execution_complete": result = frame break if frame.get("type") in ("execution_error", "backtest_error"): raise RuntimeError(frame) ``` ## Backtesting over MCP An agent connected to the MCP server does not deal with websockets. `run_backtest` takes the same `index_parameters` as a JSON string, launches the run, and immediately returns a `bkt_...` id. Poll that with `get_backtest`, which reports the performance summary and, with `include_series=true`, the downsampled value series. ### `run_backtest` Run a historical simulation of a workflow (index_parameters JSON). Validates AND runs the workflow once in preview first (structured issues + runtime errors with hints returned on failure — no separate validate_workflow or run_workflow call needed), then launches the run and immediately returns a backtest_id — poll get_backtest until status is 'completed' or 'failed'. Never creates a live index — use deploy_index. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | | `start_time` | string | no | Backtest start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to index_parameters.start_time). | | `end_time` | string | no | Backtest end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_backtest", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `get_backtest` Fetch a stored backtest result by backtest_id: performance summary and, with include_series=true, the value series evenly downsampled to max_points (default 500) for charting. Waits briefly server-side when the run is still in flight, so polling back-to-back is fine. After deploy_index, also reports the deployed live index id. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `backtest_id` | string | yes | Backtest id (bkt_...). | | `include_series` | boolean | no | Include the value series (downsampled). | | `max_points` | integer | no | Series point cap when include_series=true (default 500, max 2000). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_backtest", "arguments": { "backtest_id": "bkt_7Hs2Qa", "include_series": true } } }' ``` > **A backtest never creates an index** > > `POST /simulate` and `run_backtest` both compute and discard. Nothing is persisted and no schedule is > registered. Going live is a separate, explicitly confirmed step — see [Publish a > benchmark](/docs/guides/benchmarks). ## Previewing a fragment Before simulating twenty years, run the first few nodes once and look at what comes out. `POST /execute` takes a bare operations array — not the full envelope — runs it in preview mode against real source data, and persists nothing. It is the fastest way to find out that a column is called `splitAdjClose` and not `split_adj_close`, or that a sector filter matched zero rows. The MCP equivalent is `run_workflow`, with `inspect_run` and `get_column_values` to look at the result. ```bash curl -X POST https://api.indexone.io/execute \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"operations": [ {"id": "t", "operation": "manual_trigger", "parameters": {}}, {"id": "sec_ref", "operation": "core_securities_reference", "parameters": {"filters": [{"field": "mic", "operator": "in", "value": ["XNYS", "XNAS"]}], "attributes": ["id", "sector"]}, "input": [{"$ref": "t"}]} ]}' ``` ## Saving the workflow > **Backtesting is not deploying** > > `POST /simulate` never creates anything — it runs the graph over history and hands back the result. > To turn a graph into a live index, post it to [`POST /workflows`](/docs/reference/workflows), which validates it, > answers straight away with an `idx_` id, and finishes the backtest and live registration in the background. The > MCP equivalents are `save_workflow` for a draft and `deploy_index` for going live. `save_workflow` without a `workflow_id` creates a draft; with one it updates that workflow in place, and if the workflow is already live its changed operations trigger a live reload. It validates first and never changes stage — a draft stays a draft until `deploy_index` promotes it. - [Publish a benchmark](/docs/guides/benchmarks) — Deploy the graph and make it readable by others. - [Pull live index data](/docs/guides/pull-index-data) — Read the values and holdings the live index produces. - [Run a systematic strategy](/docs/guides/systematic-strategy) — Multi-factor scoring and optimizer-driven weights. - [Build an index with an agent](/docs/guides/agent-workflow) — The same job done entirely through MCP. --- # Pull live index data > Values, holdings, weightings and universes for a live index. A running index writes to several tables, one per artifact. This guide covers reading all of them: the value series, the holdings, the weightings, the universe and the workflow definition itself, plus how to page through a long history. ## One endpoint, several tables Workflow indices (ids beginning `idx_`) are read through `GET /query`. It is a thin, uniform wrapper over the underlying tables: you name the table, pass the index id as the partition key, and get back `{items, count, cursor}`. | Parameter | Meaning | | --- | --- | | `table` | Which table to read. See the list below. | | `pk` | Partition key — the index id for every index table. | | `sk` | Sort key value. On the time-series tables the sort key is `time`. | | `attributes` | Comma-separated projection. Ask for fewer columns and pages come back faster. | | `order` | `ascending` or `descending`. Descending plus a small `limit` is the "latest N" idiom. | | `limit` | Rows per page. | | `cursor` | Opaque base64 continuation token from the previous response. | | `map_symbols` | Resolve security identifiers to symbols in the response. | | Table | Sort key | Holds | | --- | --- | --- | | `index-parameters` | — | The workflow definition | | `index-values-eod` | `time` | The level series, one row per session | | `index-holdings` | `time` | Share counts and divisor per rebalance | | `index-weightings` | `time` | Target proportions per rebalance | | `index-universes` | `time` | Eligible securities per reconstitution | | `index-corporate-actions` | `time` | Splits, dividends and other actions applied | | `deliveries` | — | Delivery definitions | > **Both credentials** > > `/query` needs an `x-api-key` header **and** an `Authorization` header carrying a raw Cognito id token > with no `Bearer ` prefix. A request with only one of them is rejected by the gateway before CORS headers are > attached, which in a browser looks like a network error rather than a 403. See > [Authentication](/docs/start/authentication). ## Where each artifact comes from Every table maps onto an operation in the workflow that wrote it. This small index — a static symbol list loaded from a dataset, equally weighted — has one of each, which makes the correspondence easy to see. _A minimal index: each terminal operation writes one of the tables you read back._ - `trigger` — **trigger** - `load_ds` — **load_dataset** ← `trigger` - `map_ids` — **map_identifiers** ← `load_ds` - `weights` — **create_index_weighting** ← `map_ids` - `holdings` — **create_index_holdings** ← `weights` 1. create_index_weighting writes index-weightings — the target proportions. 2. create_index_holdings writes index-holdings — share counts and the divisor. 3. The default EOD value machinery writes index-values-eod once per session from those holdings. 4. This index has no create_index_universe, so index-universes is empty for it — not every workflow writes every table. ```json { "name": "Dataset Index (Symbol)", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "trigger", "operation": "trigger", "parameters": { "cron": "0 0 1 1 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "load_ds", "operation": "load_dataset", "parameters": {}, "input": [ { "$ref": "trigger" } ] }, { "id": "map_ids", "operation": "map_identifiers", "parameters": { "data": { "$ref": "load_ds.output" }, "source_column": "symbol", "target_column": "id" }, "input": [ { "$ref": "load_ds" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "map_ids.output" }, "weighting_type": "equal", "id_attribute": "id" }, "input": [ { "$ref": "map_ids" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` ## The value series The most common read. Descending order with a limit gives you the latest points; ascending with a cursor walks the history from the start. **cURL** ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-values-eod" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=descending" \ --data-urlencode "limit=30" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Python** ```python import requests HEADERS = {"x-api-key": "YOUR_API_KEY", "Authorization": "YOUR_ID_TOKEN"} response = requests.get( "https://api.indexone.io/query", headers=HEADERS, params={ "table": "index-values-eod", "pk": "idx_bh7fgXWJMaa3", "order": "descending", "limit": 30, }, ) response.raise_for_status() payload = response.json() for row in payload["items"]: print(row["time"], row.get("value_tr"), row.get("value_pr")) ``` **JavaScript** ```javascript const params = new URLSearchParams({ table: "index-values-eod", pk: "idx_bh7fgXWJMaa3", order: "descending", limit: "30", }); const response = await fetch(`https://api.indexone.io/query?${params}`, { headers: { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN" }, }); const { items, count, cursor } = await response.json(); console.log(count, items[0]); ``` **TypeScript** ```typescript interface QueryPage { items: T[]; count: number; cursor: string | null } interface ValueRow { time: string; value_pr?: number; value_tr?: number } const params = new URLSearchParams({ table: "index-values-eod", pk: "idx_bh7fgXWJMaa3", order: "descending", limit: "30", }); const response = await fetch(`https://api.indexone.io/query?${params}`, { headers: { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN" }, }); const page: QueryPage = await response.json(); console.log(page.items[0]); ``` **Go** ```go req, _ := http.NewRequest("GET", "https://api.indexone.io/query", nil) q := req.URL.Query() q.Set("table", "index-values-eod") q.Set("pk", "idx_bh7fgXWJMaa3") q.Set("order", "descending") q.Set("limit", "30") req.URL.RawQuery = q.Encode() req.Header.Set("x-api-key", "YOUR_API_KEY") req.Header.Set("Authorization", "YOUR_ID_TOKEN") resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() ``` > **Name the series you mean** > > A row carries every configured value series — typically `value_pr` (price return) and `value_tr` (total > return). They diverge by the dividend yield of the index, compounded, which over a decade is not a rounding error. > Read the workflow's `default_operations.create_index_value_eod_default.value_series` to see which series exist > and which is marked `default: true`. ## Paging with the cursor `cursor` in the response is an opaque base64 token. Pass it back as the `cursor` parameter to get the next page. It is `null` when there is nothing left — that, not an empty `items` array, is the termination condition. **Python** ```python def iter_rows(table, pk, **params): cursor = None while True: page = requests.get( "https://api.indexone.io/query", headers=HEADERS, params={"table": table, "pk": pk, "limit": 1000, **params, **({"cursor": cursor} if cursor else {})}, ) page.raise_for_status() body = page.json() yield from body["items"] cursor = body.get("cursor") if not cursor: return history = list(iter_rows("index-values-eod", "idx_bh7fgXWJMaa3", order="ascending")) print(len(history), "sessions") ``` **JavaScript** ```javascript async function* iterRows(table, pk, extra = {}) { let cursor = null; do { const params = new URLSearchParams({ table, pk, limit: "1000", ...extra }); if (cursor) params.set("cursor", cursor); const response = await fetch(`https://api.indexone.io/query?${params}`, { headers: { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN" }, }); const body = await response.json(); yield* body.items; cursor = body.cursor; } while (cursor); } ``` **cURL** ```bash # First page curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-values-eod" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=ascending" \ --data-urlencode "limit=1000" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" # Next page — paste the cursor from the previous response curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-values-eod" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=ascending" \ --data-urlencode "limit=1000" \ --data-urlencode "cursor=eyJ0aW1lIjoiMjAy…" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` ## Holdings Holdings are written per rebalance, not per session, so the latest row is the current position — read it with `order=descending&limit=1`. Each row carries the constituents with their share counts and the divisor. `map_symbols=true` resolves the identifiers to tickers, which is usually what you want if a human is going to read the output. ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-holdings" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=descending" \ --data-urlencode "limit=1" \ --data-urlencode "map_symbols=true" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` > **Shares, not weights** > > Holdings store share counts. The weight of a position today is its shares times its current price, divided > by the sum across the index — it drifts between rebalances and will not match the `index-weightings` row that > produced it. If you want the intent, read weightings; if you want the position, read holdings. ## Weightings and the universe Same shape, different tables. Weightings are the target proportions the rebalance decided on; the universe is the eligible set that the reconstitution flow produced, which is usually much larger than the holdings and changes far less often. ```bash # Latest weighting snapshot curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-weightings" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=descending" --data-urlencode "limit=1" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" # Latest universe (reconstitution) snapshot curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-universes" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=descending" --data-urlencode "limit=1" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` ## The workflow definition `index-parameters` returns the index's own definition — name, calendar settings, value series and the full operations array. It has no time sort key; there is one row per index. Reading it is how you discover, rather than assume, which value series an index publishes and how often it rebalances. ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-parameters" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` ## Statistics There is no REST statistics endpoint for `idx_` indices. Two options: compute from the value series you already pulled, or call the MCP `get_index_stats` tool, which returns cumulative and annualised return, annualised volatility and max drawdown for a chosen series over an optional date range. ### `get_index_stats` Risk/return summary from the index's EOD value series: cumulative + annualized return, annualized volatility, max drawdown. 'series' picks a value column (default 'value'). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index id (idx_...) or backtest id (bkt_...). | | `start_time` | string | no | Lower bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | Upper bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `series` | string | no | Value series column, e.g. 'value' or a TR series id. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_stats", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ## The same reads over MCP If the caller is an agent, the MCP tools are a better fit than `/query`: they take an `index_id` and return shaped results rather than raw table rows, and they downsample long series to a point cap while always keeping the latest point. | Table read | MCP tool | | --- | --- | | `index-values-eod` | `get_index_values` | | `index-holdings` | `get_index_holdings` | | `index-weightings` | `get_index_weightings` | | `index-universes` | `get_index_universe` | | `index-parameters` | `get_index` | | — (computed) | `get_index_stats` | > **Dedicated REST endpoints are planned, not shipped** > > `GET /workflows/{id}/values`, `/holdings`, `/weightings`, `/universe` and `/stats` appear in the > [workflows reference](/docs/reference/workflows) marked *planned*. Until they ship, `GET /query` and the MCP tools > are the supported read paths — build against `/query` and you will not have to change anything when the convenience > routes arrive. - [Consume a delivery](/docs/guides/deliveries-consume) — Have the data pushed to you instead of polling for it. - [Corporate actions](/docs/guides/corporate-actions) — Reading the actions table and handling restatements. - [Data query](/docs/reference/data-query) — Every queryable table and how the cursor works. - [Authentication](/docs/start/authentication) — Getting an id token to pair with your API key. --- # Publish a benchmark > Deploy an index and make it public in the directory. A benchmark is an index other people depend on. That raises the bar in two places: the rebalance logic has to be stable and auditable, and the index has to keep calculating on a schedule without anyone touching it. This guide covers the standard shape for a benchmark workflow, then the path from a backtest to a published index other teams can read. ## The two-flow pattern A benchmark almost never runs its selection logic and its weighting logic on the same schedule. Membership changes rarely and deliberately — that is a **reconstitution**. Weights are refreshed more often to keep the index close to its stated methodology — that is a **rebalance**. Putting them in one flow forces both to share a cadence, which is wrong in both directions: either membership churns quarterly, or weights go stale for a year. The fix is two triggers in one workflow, wired as below. _US 500 — annual reconstitution (ops 0–6) and quarterly rebalance (ops 00–04) as two independent flows._ - `0` — **trigger** - `1` — **core_securities_reference** ← `0` - `4` — **i1_core_quote** ← `1` - `5` — **filter** ← `4` - `6` — **create_index_universe** ← `5` - `00` — **trigger** - `01` — **get_index_universe** ← `00` - `02` — **i1_core_quote** ← `01` - `03` — **create_index_weighting** ← `02` - `04` — **create_index_holdings** ← `03` 1. Flow A, annually: trigger 0 fires on 1 January, aligned to the preceding XNYS close. 2. core_securities_reference (1) builds the eligible cross-section; i1_core_quote (4) attaches market cap; filter (5) takes the top 500. 3. create_index_universe (6) persists that membership list. Flow A ends here — it never touches weights. 4. Flow B, quarterly: trigger 00 fires on 1 January, April, July and October. 5. get_index_universe (01) reads back the stored universe with an as_of time filter — the membership decided at the last reconstitution. 6. i1_core_quote (02) prices those members today; create_index_weighting (03) weights them proportionally by market cap. 7. create_index_holdings (04) converts weights into share counts and a divisor. ```json { "name": "US 500 Index", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "0", "operation": "trigger", "parameters": { "cron": "0 0 1 1 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "1", "operation": "core_securities_reference", "parameters": { "filters": [ { "field": "mic", "operator": "in", "value": [ "XNYS", "XNAS" ] }, { "field": "security_type", "operator": "eq", "value": "Common Stock" }, { "field": "exchange_country", "operator": "eq", "value": "US" }, { "field": "domicile_country", "operator": "eq", "value": "US" } ], "attributes": [ "id" ] }, "input": [ { "$ref": "0" } ] }, { "id": "4", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "1.output#id" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "marketCap" ] }, "input": [ { "$ref": "1" } ] }, { "id": "5", "operation": "filter", "parameters": { "data": { "$ref": "4.output" }, "filters": [ { "field": "marketCap", "operator": "top_n", "value": 500 } ] }, "input": [ { "$ref": "4" } ] }, { "id": "6", "operation": "create_index_universe", "parameters": { "universe": { "$ref": "5.output#id" }, "identifier": "id" }, "input": [ { "$ref": "5" } ] }, { "id": "00", "operation": "trigger", "parameters": { "cron": "0 0 1 1,4,7,10 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "01", "operation": "get_index_universe", "parameters": { "filters": [ { "field": "time", "operator": "as_of" } ] }, "input": [ { "$ref": "00" } ] }, { "id": "02", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "01.output#security" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "splitAdjClose", "marketCap" ] }, "input": [ { "$ref": "01" } ] }, { "id": "03", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "02.output" }, "weighting_type": "proportional", "id_attribute": "id", "weight_attribute": "marketCap" }, "input": [ { "$ref": "02" } ] }, { "id": "04", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "03.output" } }, "input": [ { "$ref": "03" } ] } ] } ``` The join between the flows is `get_index_universe` with `{"field": "time", "operator": "as_of"}`. With no value, `as_of` defaults to the request time, so it selects the single latest universe snapshot at or before this rebalance. In January the two flows fire on the same date and the rebalance picks up that morning's new membership; in April, July and October it picks up the same January list. Note also that the rebalance reads `01.output#security` — the universe table's identifier column is `security`, not `id`. Column names differ between operations and are worth checking with a preview run rather than assuming. > **Why the split matters for auditability** > > With two flows, "did the constituents change?" is answered by looking at one table with four rows a decade, > and "why did this weight move?" is answered by another with quarterly rows. A single combined flow blurs the two, > and a quarterly run can silently drop a name that only failed the size screen for a week. ## Before deploying Deploying computes and persists real history under a real index id. Get the graph right first. 1. **Preview the fragments** Run the selection flow alone through `POST /execute` (or MCP `run_workflow`) and check the row count and the columns. A benchmark that quietly selects 480 names instead of 500 because a filter dropped nulls is the kind of thing you want to find now. 2. **Validate the whole workflow** MCP `validate_workflow` checks the structural rules — every flow starts with a trigger, the workflow includes `create_index_holdings`, every flow persists a result — and the parameter schemas. It returns structured issues rather than a boolean. 3. **Backtest over the full intended history** `POST /simulate`, or MCP `run_backtest`. Check that the level series is continuous across every reconstitution date; a step change there means holdings and divisor disagree. 4. **Fix the start, then stop changing the graph** `start_time`, `start_value` and `start_divisor` define the series a consumer will quote forever. Changing them after publication restates every point. ## Deploy Over REST, deployment is [`POST /workflows`](/docs/reference/workflows). It validates the graph, answers `202` with the new `idx_` id and a `bkt_` id, then runs the backtest and registers the index in the background — poll `GET /workflows/{workflow_id}` until `stage` leaves `pending`. `POST /simulate` only ever simulates; it creates nothing. The MCP equivalent is `deploy_index`, which takes either a saved `workflow_id` or inline `index_parameters` and requires `confirm=true`. Both paths run the same code, including the check that refuses to create an index whose backtest produced no holdings. ### `deploy_index` Create a LIVE index from a saved workflow_id or inline index_parameters: validates AND preview-verifies the workflow (runtime problems bounce with hints; verify=false skips the preview), then runs a full backtest, persists its history, and registers the index for continuous scheduled calculation. Requires confirm=true. Returns the pending index_id + backtest_id immediately — poll get_index(index_id) or get_backtest(backtest_id) until the index lands on 'live' or 'failed' (deployed_index_id appears in get_backtest when done). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | no | Saved workflow id to deploy. | | `index_parameters` | string | no | JSON string of index_parameters (alternative to workflow_id). | | `start_time` | string | no | History start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | History end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `confirm` | boolean | no | Must be true to actually deploy. | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "deploy_index", "arguments": { "workflow_id": "idx_bh7fgXWJMaa3", "confirm": true } } }' ``` > **deploy_index is the one irreversible step** > > `run_backtest` never creates a live index. `deploy_index` does, and the `confirm=true` flag exists so > an agent cannot do it by accident. Once deployed, the index has an id that consumers can reference and a schedule > that keeps calculating. Treat it as a publication event, not an experiment. A full-history deploy takes minutes, so the tool returns a `bkt_...` id immediately. Poll it with `get_backtest`; `deployed_index_id` appears there when the run finishes, and that is the `idx_...` id you publish. 1. **Save the workflow as a draft** MCP `save_workflow` with no `workflow_id`. It validates first and returns the draft id. ```json { "name": "save_workflow", "arguments": { "index_parameters": "{ …the JSON string… }" } } ``` 2. **Deploy it** Explicit confirmation, and a history start that matches the workflow's `start_time`. ```json { "name": "deploy_index", "arguments": { "workflow_id": "idx_…", "start_time": "2019-12-28 00:00:00", "confirm": true } } ``` 3. **Poll for the live id** Repeat until `deployed_index_id` is present. ```json { "name": "get_backtest", "arguments": { "backtest_id": "bkt_…" } } ``` 4. **Confirm it is calculating** Read `index-values-eod` for the new id after the next session close. A live index gains one row per session. ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-values-eod" \ --data-urlencode "pk=idx_…" \ --data-urlencode "order=descending" --data-urlencode "limit=3" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` ## Publishing it A deployed index is visible to your team only. Making it public is a property of the index, set in the console on the index's settings: public indices appear in the workflows directory and can be read by anyone; featured ones are additionally surfaced on the directory landing page. Once public, an index shows up under `list_workflows` with `scope: "public"` — that is how a consumer or an agent discovers it without being told the id. ### `list_workflows` List saved workflows. scope='team' (the user's), 'public' (public/featured), or 'all'. When the user names a specific index/workflow, pass search=[] in ONE call — a workflow matching ANY fragment is returned, tagged with which matched, and fragments that hit nothing are listed back. The plain listing is sorted by name and cut at 'limit', so the one you need may not be in it (the response says when it was cut). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `scope` | "team" \\| "public" \\| "all" | no | Default 'team'. | | `search` | array | no | One or more case-insensitive fragments matched on name/description/id; put every candidate name in the same call. | | `limit` | integer | no | Default 50. | | `offset` | integer | no | Skip the first N (pagination). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_workflows", "arguments": { "scope": "public", "limit": 10 } } }' ``` ## What consumers do next Publish the id and the series name together. A benchmark quoted without saying whether it is price return or total return is ambiguous by several percent a year. ```bash # The definition — calendar, rebalance cadence, value series curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-parameters" --data-urlencode "pk=idx_…" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" # The level series curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-values-eod" --data-urlencode "pk=idx_…" \ --data-urlencode "order=ascending" --data-urlencode "limit=1000" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" # Current constituents curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-holdings" --data-urlencode "pk=idx_…" \ --data-urlencode "order=descending" --data-urlencode "limit=1" \ --data-urlencode "map_symbols=true" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` Consumers who would rather be pushed to than poll should be set up with a delivery instead — a scheduled graph that emails, POSTs or SFTPs the panel after each publication. See [Set up a delivery](/docs/guides/deliveries-setup). ## Keeping it stable - Editing a live workflow's operations through `save_workflow` triggers a live reload. That is the intended way to fix a bug, but it changes future calculation — record why. - A restatement rewrites history that was already published. Consumers who cached the old values will not notice unless you tell them; see [Corporate actions](/docs/guides/corporate-actions). - Universe and weighting tables are the audit trail. Do not prune them. - Alignment settings are part of the methodology. Changing `trading_day` or `session_time` moves every future rebalance date. - [Build and backtest an index](/docs/guides/build-and-backtest) — The graph mechanics this guide assumes. - [Pull live index data](/docs/guides/pull-index-data) — What a consumer of your benchmark will call. - [Set up a delivery](/docs/guides/deliveries-setup) — Push the benchmark instead of waiting to be polled. - [Corporate actions](/docs/guides/corporate-actions) — Keeping the level continuous across splits and dividends. --- # Set up a delivery > Push index files to email, webhook or SFTP on a schedule. A delivery pushes index output to a destination. It is a separate object from the index — `dlv_` rather than `idx_`, stored in `{stage}-deliveries` — but the execution coordinator picks it up alongside workflows and runs it through the same engine. That separation is deliberate: recalculating an index and shipping its output fail for different reasons and want different retry behaviour, so they are separate runs. A delivery **is** an ordinary workflow. It is stored once, as a plain `operations` list, and there is nothing the platform expands at runtime. There are exactly two shapes. - **Standard** — `trigger` → `index_panel` → one of `send_email`, `send_webhook`, `send_sftp`, `send_s3`. The panel decides *what* the file contains, the send op decides *where* it goes. - **Partner** — `trigger` → one of `send_alphabot`, `send_alphathena`, `send_stratifi`, `send_refinitiv`. No panel node, because the partner operation is the whole flow. The console's delivery form is the easy path, and it writes exactly those nodes — it reads them back too, so the form and the workflow builder are two views of one stored graph. There is no "declarative config" mode opposite a "custom DAG" mode, and nothing is converted when you move between them: open a form-built delivery in the builder and you see the operations that run. ## The standard shape _A standard delivery: trigger → index_panel → send op. Shown here with the webhook channel._ - `delivery_trigger` — **index_event_trigger** - `delivery_payload` — **index_panel** ← `delivery_trigger` - `delivery_send` — **send_webhook** ← `delivery_payload` 1. A trigger operation — `index_event_trigger` to fire on a publication, `trigger` for cron, `manual_trigger` for a human gate. 2. `index_panel` builds one flat file per index from the chosen `payload_type` and `history`, and outputs file artifacts. 3. One send operation ships those artifacts. Swapping the channel is swapping this node; the panel above it does not change. ```json { "name": "Daily values to a webhook", "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "payload_type": "values", "history": "latest" } }, { "id": "delivery_send", "operation": "send_webhook", "use_cache": false, "input": [ { "$ref": "delivery_payload" } ], "parameters": { "url": "https://example.com/hooks/indexone", "method": "POST", "headers": { "x-shared-secret": "REDACTED" }, "per_index": true } } ] } ``` Two wiring details are load-bearing. The panel takes its targets from the trigger — `index_ids: { "$ref": "delivery_trigger.output.fired_index_ids" }` — so a delivery watching several indices builds a file only for the ones that actually published. And a `$ref` edge has to appear in `input` as well as in `parameters`; `input` is what orders the graph. ## Creating one `POST /deliveries` takes the delivery body. `operations` is required — a request without it is rejected with `{"error": "operations required"}`. | Field | Meaning | | --- | --- | | `team_id` | Owning team. | | `name` | Display name. Required. | | `description` | Free text. | | `stage` | `live` runs it, `paused` stops it firing. Defaults to `live`. | | `timezone` | Timezone recorded on the delivery. Cron triggers take their own `timezone` parameter. | | `index_id` / `index_ids` | The index or indices this delivery is about. Operations that take no explicit target fall back to these. | | `operations` | **Required.** The delivery graph: a trigger, then `index_panel` + a send op, or a partner send op alone. | **cURL** ```bash curl -X POST https://api.indexone.io/deliveries \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "team_id": "YOUR_TEAM_ID", "name": "Daily values to ops", "stage": "live", "timezone": "US/Eastern", "index_ids": ["idx_bh7fgXWJMaa3"], "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [{ "$ref": "delivery_trigger" }], "parameters": { "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "payload_type": "values", "history": "latest", "filename_template": "{index_name}_values_{date}.csv" } }, { "id": "delivery_send", "operation": "send_email", "use_cache": false, "input": [{ "$ref": "delivery_payload" }], "parameters": { "recipients": ["ops@example.com"], "subject": "Index values {date}", "body": "Attached: end-of-day values." } } ] }' ``` **Python** ```python import requests HEADERS = {"x-api-key": "YOUR_API_KEY", "Authorization": "YOUR_ID_TOKEN"} delivery = { "team_id": "YOUR_TEAM_ID", "name": "Daily values to ops", "stage": "live", "timezone": "US/Eastern", "index_ids": ["idx_bh7fgXWJMaa3"], "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": False, "parameters": {"index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod"}, }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": False, "input": [{"$ref": "delivery_trigger"}], "parameters": { "index_ids": {"$ref": "delivery_trigger.output.fired_index_ids"}, "payload_type": "values", "history": "latest", "filename_template": "{index_name}_values_{date}.csv", }, }, { "id": "delivery_send", "operation": "send_email", "use_cache": False, "input": [{"$ref": "delivery_payload"}], "parameters": { "recipients": ["ops@example.com"], "subject": "Index values {date}", "body": "Attached: end-of-day values.", }, }, ], } response = requests.post("https://api.indexone.io/deliveries", headers=HEADERS, json=delivery) response.raise_for_status() print(response.json()) ``` **JavaScript** ```javascript const response = await fetch("https://api.indexone.io/deliveries", { method: "POST", headers: { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ team_id: "YOUR_TEAM_ID", name: "Daily values to ops", stage: "live", timezone: "US/Eastern", index_ids: ["idx_bh7fgXWJMaa3"], operations: [ { id: "delivery_trigger", operation: "index_event_trigger", use_cache: false, parameters: { index_id: "idx_bh7fgXWJMaa3", event_type: "value_eod" }, }, { id: "delivery_payload", operation: "index_panel", use_cache: false, input: [{ $ref: "delivery_trigger" }], parameters: { index_ids: { $ref: "delivery_trigger.output.fired_index_ids" }, payload_type: "values", history: "latest", filename_template: "{index_name}_values_{date}.csv", }, }, { id: "delivery_send", operation: "send_email", use_cache: false, input: [{ $ref: "delivery_payload" }], parameters: { recipients: ["ops@example.com"], subject: "Index values {date}", body: "Attached: end-of-day values.", }, }, ], }), }); console.log(await response.json()); ``` > **default_operations is not part of this** > > `default_operations` still exists, and still holds genuine engine defaults — `create_index_value_eod_default` > for EOD valuation, `create_index_corporate_actions_default` for corporate actions. Those are single operations > that do many steps internally; they are not shorthand for a graph. Deliveries do not use `default_operations` at > all. If you are carrying an older integration that wrote a delivery there, move it to `operations`. ## Triggers | Operation | Fires when | Use it for | | --- | --- | --- | | `index_event_trigger` | A target index publishes data of the chosen `event_type` | Sending as soon as the data exists, with no guessing about timing | | `trigger` | A cron expression, in the operation's `timezone` | A fixed contractual send time, or a digest that does not track publication | | `manual_trigger` | A human resumes the suspended run | Approval gates and ad-hoc resends | `index_event_trigger` is the right default. Its `event_type` selects which publication to listen for: `value_eod` for a new end-of-day value, `universe` for a reconstitution, `weighting` for a rebalance, `holdings` for a holdings update. A rebalance file that goes out on `weighting` cannot arrive before the weights exist; the same file on a cron can. `index_id` is normally a single id string and also accepts a list — the trigger fires when *any* listed index publishes, and passes only those ids downstream in `output.fired_index_ids`. ```json { "id": "delivery_trigger", "operation": "trigger", "use_cache": false, "parameters": { "cron": "0 17 * * 1-5", "timezone": "US/Eastern" } } ``` On a cron or manual trigger there are no fired ids to inherit, so give `index_panel` an explicit `index_ids` list instead of the `$ref`. > **Restatements do not fire events** > > `index_event_trigger` listens to table streams, and rows older than about three hours are treated as > restatements — they never fire. So a corrected historical value will not push a new delivery. If your consumers > need restatements, they have to re-pull; see [Corporate actions](/docs/guides/corporate-actions). ## The payload: index_panel `index_panel` reads the index tables for each target and renders a flat table — one header row, one record per row, always carrying an `index_id` column. It outputs file artifacts, `[{index_id, index_name, payload_type, time, data, filename}]`, which the channel send operations consume. | Parameter | Meaning | | --- | --- | | `payload_type` | `values`, `weightings`, `holdings`, `changes`, `corporate_actions` or `tracker_legacy`. Defaults to `values`. | | `history` | `latest` (the most recent snapshot) or `full` (the whole series). Defaults to `latest`. | | `index_ids` | Targets. Defaults to the upstream trigger's fired indices, then the delivery's own targets. | | `filename_template` | Defaults to `{index_name}_{payload_type}_{date}.csv`. Also supports `{index_id}`, `{yyyymmdd}`, `{timestamp}`. | | `category` | Identifier to key positions by on `weightings` / `holdings` / `changes` — e.g. `symbol`. Omit to keep the stored FIGI. | | `columns` | Project and rename output columns: `{"security": "id", "weight": "weight"}` keeps only those, in that order. | | `include_unchanged` | `changes` panels only: also emit rows for positions that did not move. | | `files` | Build several files from one node. Each entry overrides this node's own parameters; the artifacts come back as one flat list, so a single send op delivers them all. | `history` decides how much goes out. `latest` is the normal setting for a recurring feed. `full` is what you want for a backfill or a first send to a new counterparty, and what you do not want daily. ```json { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [{ "$ref": "delivery_trigger" }], "parameters": { "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "category": "symbol", "files": [ { "payload_type": "values", "history": "full", "filename_template": "returns_{index_id}_{yyyymmdd}.csv" }, { "payload_type": "weightings", "history": "latest", "filename_template": "positions_{index_id}_{yyyymmdd}.csv" } ] } } ``` > **tracker_legacy is a snapshot, not a series** > > `payload_type: "tracker_legacy"` reproduces the v1 tracker file — every position of the latest snapshot > with its add/remove/hold state, in the legacy column order. It rejects `history: "full"` and `columns` with a > config error, and an errored live execution group is parked permanently, so do not set them. Use it only for > partners that parse that exact file; flat panels are preferable for anything new. ## The four channels ### Email ```json { "id": "delivery_send", "operation": "send_email", "use_cache": false, "input": [{ "$ref": "delivery_payload" }], "parameters": { "recipients": ["ops@example.com", "risk@example.com"], "subject": "US 500 holdings — {date}", "body": "Quarterly rebalance holdings attached." } } ``` One email via SES with every upstream artifact attached as a CSV, named by `filename_template`. `subject` and `body` support `{date}` and `{payload_type}`. Email is fine for humans and for counterparties with an inbox-based intake process; it is a poor fit for anything automated, because you get no delivery confirmation you can act on. ### Webhook ```json { "id": "delivery_send", "operation": "send_webhook", "use_cache": false, "input": [{ "$ref": "delivery_payload" }], "parameters": { "url": "https://example.com/hooks/indexone", "method": "POST", "headers": { "x-shared-secret": "…" }, "per_index": true } } ``` `per_index: true` (the default) issues one request per index, each body `{index_id, index_name, payload_type, time, generated_at, records: [...]}`. Set it to `false` and you get a single request bundling every envelope under `deliveries`. One request per index is usually what a receiver wants, since each then has a single subject and can be processed independently. There is no signature on delivery webhooks today, so put a shared secret in `headers` and check it on receipt. The [consuming guide](/docs/guides/deliveries-consume) covers what a receiver should do with the request. > **Endpoints behind a token** > > `auth` fetches a bearer immediately before every send: > `{url, method, headers, body, token_path, header, prefix}`. Do this rather than adding an upstream node that > fetches the token — an upstream node is executed once at registration and its result reused forever, so the > token goes stale and the sends start failing. ### SFTP ```json { "id": "delivery_send", "operation": "send_sftp", "use_cache": false, "input": [{ "$ref": "delivery_payload" }], "parameters": { "host": "sftp.example.com", "port": 22, "username": "indexone", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n…", "directory": "/incoming/indices" } } ``` Supply either `password` or `private_key`; a key is preferable. Each artifact lands in `directory` under its own filename, unless `filename` overrides it. SFTP is the format most institutional counterparties already have an intake process for, and unlike email it gives the receiver a directory they can poll and reconcile. ### S3 ```json { "id": "delivery_send", "operation": "send_s3", "use_cache": false, "input": [{ "$ref": "delivery_payload" }], "parameters": { "bucket": "EXPORT_BUCKET", "key_template": "{team_id}/{index_id}_{payload_type}_{date}.csv" } } ``` `bucket` accepts the logical name `EXPORT_BUCKET` — the default, which resolves to the customer-facing export bucket — or a literal bucket name so a team can write into its own. `key_template` supports `{team_id}`, `{index_id}`, `{index_name}`, `{payload_type}`, `{date}`, `{yyyymmdd}`, `{timestamp}` and `{filename}`. > **Filenames are the only versioning** > > A delivery that writes the same filename every run overwrites yesterday's file if the receiver has not moved > it. Put the date in `filename_template` (or `key_template`) — `{index_id}_holdings_{date}.csv` — so a missed > pickup does not become silent data loss. ## Partner integrations A partner accepts exactly one data shape over exactly one transport. "Weightings over SFTP" for a partner that wants a return series over its own API simply does not work, so partner operations are not building blocks — each one is a whole flow. The operation reads the data it needs, formats it the one way that partner accepts, and sends it over the one transport that partner speaks. That leaves a partner delivery with two nodes and almost nothing to configure: the targets, and the per-index **account binding** — the identifier that partner knows your index by. _A partner delivery: trigger → send_. There is no index_panel — the partner op reads its own data._ - `delivery_trigger` — **index_event_trigger** - `delivery_send` — **send_refinitiv** ← `delivery_trigger` 1. The same trigger operations as any other delivery; `value_eod` is what live partner rules normally fire on. 2. The partner send op takes its targets from `fired_index_ids` and maps each one to its account binding. 3. Formatting and transport are inside the operation. There is nothing between the two nodes to configure. ```json { "name": "Closing values to LSEG", "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_send", "operation": "send_refinitiv", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "ric_map": { "idx_bh7fgXWJMaa3": ".SPLTPR" } } } ] } ``` | Operation | What is sent | Per-index binding | Credentials | | --- | --- | --- | --- | | `send_refinitiv` | Latest EOD close, contributed as `HST_CLOSE` / `HSTCLSDATE` over an LSEG tr_json2 websocket | `ric_map` — `{index_id: RIC}` | Index One's own | | `send_alphabot` | The full inception-to-date daily return series, re-sent every run with `Replace: true` | `instrument_ids` — `{index_id: InsId}` | Index One's own | | `send_alphathena` | Current symbol-keyed weights, PUT to a saved index | `index_uuids` — `{index_id: uuid}` | `api_key` — your team's | | `send_stratifi` | A returns file and a positions file per index, over SFTP | none — the target list is enough | Index One's own | An index with no entry in the binding map is reported as a per-target failure rather than failing the run, so adding an index to the trigger without adding its RIC produces a visible, recoverable warning. The console's **Integrations** page is the front end for all of this: pick the indices, fill the binding column, save. ## What a failed send does Every send operation reports per-target rows — `output.results = [{index_id, target, ok, attempts, detail}]` — plus `sent`, `sent_count` and `failed_count`. One index failing does not stop the others, so the rows, not a single boolean, are the result of a delivery. Retries are controlled by `retries` on every send op (default `2` attempts after the first). Webhooks retry 408, 429 and 5xx and transport errors; a 4xx is not retried, because repeating the same request will not change the answer. > **A failed send is a warning, never an error** > > A send that fails after its retries sets `_state.warning` with `sent: false` — deliberately, and it > matters. The coordinator **parks a live execution group permanently** once it errors, and never re-registers it, > so a single transient partner 500 recorded as an error would silently stop the delivery until somebody POSTed > `/trigger` by hand. Delivery failures must be visible but non-terminal: the next event re-fires the group. The > consequence for you is that "the run completed" is not "the file arrived" — read `results`. ## Testing without sending Send operations resolve one of three modes from the execution context, and it is worth knowing which one you are in. | Mode | When | What happens | | --- | --- | --- | | `live` | `execution_mode: "live"` — the real scheduled run | The send happens. | | `dry_run` | Preview, including the workflow builder | No send. Returns `{"would_send": true, …}` with the destination and the files it would have used. | | `blocked` | Backtest | No send, ever. Returns `{"sent": false, "reason": "sends are disabled in backtest mode"}`. | The backtest block is what stops a twenty-year simulation of an index that has a delivery attached from mailing a counterparty five thousand times. The dry run is the deliberate test: the delivery runs for real and builds the real panel, then stops at the send — use it to check the file contents and the filename before the first live send. To actually send once on demand, the console's **Test delivery** button posts to `/execute` with `request_context.delivery_test: true`, which promotes a preview run to a real send. ```json { "sent": false, "reason": "sends are disabled in backtest mode" } ``` ## Managing deliveries | Route | Notes | | --- | --- | | `POST /deliveries` | Create. `operations` required. Succeeds with **200, not 201**. | | `GET /deliveries/{id}` | **`{id}` is the team id**, not the delivery id — it lists the team's deliveries. | | `PATCH /deliveries/{id}` | Update a delivery, by delivery id. Patch `operations` to change the graph, `stage` to pause or resume. | | `DELETE /deliveries/{id}` | Delete a delivery, by delivery id. | | `GET /deliveries/{id}/executions` | Run history for one delivery, by delivery id. | > **GET /deliveries/{id} takes a team id — the other routes do not** > > This is the most common mistake against these routes. The list route is keyed by team; create, patch, delete > and executions are all keyed by the `dlv_` id. Passing a delivery id to the list route returns nothing rather > than an error, which reads like "the delivery does not exist". ## Beyond the form Because a delivery is just a workflow, anything the form does not offer is a matter of adding nodes rather than switching to a different kind of object. Join index output against your own dataset before sending, filter the panel down to a subset of constituents, compute derived columns, fan out to two destinations from one panel — all of it is the same graph model as an index workflow, ending in a send operation. The one thing to keep in mind: the console form only recognises the exact three-node shape it writes. Add a fourth node and the delivery still runs, and still opens in the workflow builder, but the row is listed as custom and the one-click dialog will not open it. - [Consume a delivery](/docs/guides/deliveries-consume) — Panel formats, a webhook receiver and replay handling. - [Deliveries reference](/docs/reference/deliveries) — Every route and field. - [Corporate actions](/docs/guides/corporate-actions) — Why restatements do not trigger a send. - [Core concepts](/docs/start/concepts) — How deliveries relate to workflows. --- # Consume a delivery > Panel formats, payload types and a webhook receiver. This is the receiving side. Someone has set up a delivery pointed at you — an SFTP directory, a webhook endpoint, an S3 prefix, or a mailbox — and you have to turn what arrives into something your systems can act on. That means knowing the file format, knowing which payload type you are getting, and handling the two things that will eventually happen: a duplicate send, and a send you missed. ## What is actually sent A delivery is an ordinary workflow on the sender's side — a trigger, an `index_panel` node, and one send node. `index_panel` is what produces your file: a flat table, one header row, one record per row, no merged headers or spanning cells, always carrying an `index_id` column. The send node only decides how that file reaches you. _The sender side of what you receive: an event fires, index_panel builds the file, a send op ships it._ - `delivery_trigger` — **index_event_trigger** - `delivery_payload` — **index_panel** ← `delivery_trigger` - `delivery_send` — **send_sftp** ← `delivery_payload` 1. `index_event_trigger` fires when the index publishes — here, on a new weighting (a rebalance). 2. `index_panel` renders the chosen `payload_type`. `history: "latest"` means the newest snapshot only, and `category: "symbol"` keys the rows by ticker instead of FIGI. 3. `send_sftp` writes it into the agreed directory under the filename from `filename_template`. ```json { "name": "Rebalance changes to SFTP", "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "weighting" } }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "payload_type": "changes", "history": "latest", "category": "symbol", "filename_template": "{index_id}_changes_{yyyymmdd}.csv" } }, { "id": "delivery_send", "operation": "send_sftp", "use_cache": false, "input": [ { "$ref": "delivery_payload" } ], "parameters": { "host": "sftp.example.com", "port": 22, "username": "indexone", "directory": "/incoming/indices" } } ] } ``` | Channel | What you get | How you pick it up | | --- | --- | --- | | `send_email` | The CSV as an attachment, with the configured subject and body | Mailbox rules, or a scripted IMAP fetch | | `send_webhook` | An HTTP request to your endpoint, one per index unless `per_index` is off | An HTTP handler you own | | `send_sftp` | The CSV written into an agreed directory | Poll the directory, or an inotify-style watcher | | `send_s3` | The CSV written to an object key | S3 event notification, or poll the prefix | If the sender is feeding you through a partner integration instead — `send_refinitiv`, `send_alphabot`, `send_alphathena`, `send_stratifi` — there is no `index_panel` node and none of this applies: those operations format and transport the data the one way that partner accepts, and you consume it through that partner's own product. ## The payload types Establish with the sender which of these you are receiving before you write a parser. They have different grains and different keys. | `payload_type` | Grain | What it answers | | --- | --- | --- | | `values` | One row per session | "What is the index level?" — carries every configured series, typically `value_pr` and `value_tr`. | | `weightings` | One row per constituent, per rebalance | "What proportions was the index targeting?" | | `holdings` | One row per constituent, per rebalance | "What does the index actually own?" — share counts plus the divisor. | | `changes` | One row per changed position | "What moved since last time?" — adds, drops and rebalances. | | `corporate_actions` | One row per action | "What splits and dividends were applied?" | | `tracker_legacy` | One file per snapshot | The v1 tracker layout, kept for systems that parse that exact file. Not a flat panel — see below. | The leading columns are fixed per payload type, and anything else the record carries is appended after them: | `payload_type` | Leading columns | | --- | --- | | `values` | `index_id`, `time`, `value`, then each configured series | | `weightings` | `index_id`, `time`, `security`, `weight` | | `holdings` | `index_id`, `time`, `security`, `weight`, `shares`, `divisor` | | `changes` | `index_id`, `time`, `security`, `change_type`, then `shares_prev` / `shares_new` / `shares_change` and `weight_prev` / `weight_new` / `weight_change` | | `corporate_actions` | `index_id`, `time`, `security`, `type`, then the action's own fields (nested ones such as `impact` arrive JSON-encoded) | `change_type` on a `changes` panel is one of `added`, `removed` or `rebalance`. If the sender set `include_unchanged`, you will also see `hold` rows for positions that did not move — so do not treat the presence of a row as proof that something changed. > **weightings and holdings are not interchangeable** > > Weightings are the target proportions the rebalance decided on. Holdings are share counts, which drift away > from those proportions as prices move and change again on corporate actions. If you are replicating the index, you > want holdings. If you are checking methodology, you want weightings. Reconciling one against the other and finding > differences is expected, not a bug. > **Ask what `security` is keyed by** > > By default the `security` column holds the stored identifier, which is a FIGI. If the sender set > `category` on the panel — `symbol` is the common one — the column holds that identifier instead, and duplicates > are summed on the way. Two feeds from the same index can therefore key positions differently. Agree it once, > in writing. `history` on the sender side decides how much arrives. `latest` sends the newest snapshot only — the normal recurring case, and the one where a missed file is a real gap. `full` sends the whole series, which is self-healing but large. ## A webhook receiver With `per_index: true` — the default — you get one request per index, and the body is a records-orient envelope. With `per_index: false` you get a single request carrying `{generated_at, deliveries: [...]}`, where each entry is one of these envelopes. Handle whichever the sender configured; ask, rather than inferring it from the first request you see. ```json { "index_id": "idx_bh7fgXWJMaa3", "index_name": "US 500", "payload_type": "weightings", "time": "2026-06-30 00:00:00", "generated_at": "2026-06-30 21:12:04", "records": [ { "index_id": "idx_bh7fgXWJMaa3", "time": "2026-06-30 00:00:00", "security": "AAPL", "weight": 0.0712 }, { "index_id": "idx_bh7fgXWJMaa3", "time": "2026-06-30 00:00:00", "security": "MSFT", "weight": 0.0664 } ] } ``` `time` is the snapshot the data belongs to; `generated_at` is when the file was built. They are not the same and the difference matters for idempotency — a resend of the same snapshot carries the same `time` and a new `generated_at`. One exception: a `tracker_legacy` payload is an already-rendered file body, so it arrives as `content` (a string) plus `filename`, with no `records` array. Every other payload type sends `records`. The important properties of a good receiver: it accepts the request quickly, it is idempotent, and it never does the downstream work inline. Acknowledge, persist the raw body, and process asynchronously — that way a slow database does not turn into a failed delivery. **Python** ```python # FastAPI import hmac, os from fastapi import FastAPI, Header, HTTPException, Request app = FastAPI() SHARED_SECRET = os.environ["INDEXONE_SHARED_SECRET"] @app.post("/hooks/indexone") async def receive(request: Request, x_shared_secret: str = Header(default="")): # There is no signature on delivery webhooks — a shared secret in a header # is the available control. Compare in constant time. if not hmac.compare_digest(x_shared_secret, SHARED_SECRET): raise HTTPException(status_code=401) body = await request.body() payload = await request.json() # per_index: false bundles every index under "deliveries". envelopes = payload.get("deliveries") or [payload] accepted = [] for envelope in envelopes: # tracker_legacy arrives as a rendered file body, everything else as rows. rows = envelope.get("records") if rows is None: rows = parse_legacy_file(envelope["content"]) # Idempotency key: the sender does not provide one, so derive it from the # SNAPSHOT time — generated_at changes on every resend of the same data. key = f"{envelope['index_id']}:{envelope['payload_type']}:{envelope['time']}" if already_seen(key): accepted.append({"key": key, "status": "duplicate"}) continue store_raw(key, body) enqueue_processing(key, envelope["index_id"], rows) accepted.append({"key": key, "status": "accepted", "rows": len(rows)}) return {"results": accepted} ``` **JavaScript** ```javascript // Express import express from "express"; import crypto from "node:crypto"; const app = express(); app.use(express.json({ limit: "50mb" })); const SHARED_SECRET = process.env.INDEXONE_SHARED_SECRET; app.post("/hooks/indexone", async (req, res) => { const supplied = Buffer.from(req.get("x-shared-secret") ?? ""); const expected = Buffer.from(SHARED_SECRET); if (supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) { return res.sendStatus(401); } // per_index: false bundles every index under "deliveries". const envelopes = req.body.deliveries ?? [req.body]; const results = []; for (const envelope of envelopes) { const rows = envelope.records ?? parseLegacyFile(envelope.content); // Key on the snapshot time, not generated_at — a resend of the same // snapshot carries the same time and a fresh generated_at. const key = `${envelope.index_id}:${envelope.payload_type}:${envelope.time}`; if (await alreadySeen(key)) { results.push({ key, status: "duplicate" }); continue; } await storeRaw(key, envelope); await enqueueProcessing(key, envelope.index_id, rows); results.push({ key, status: "accepted", rows: rows.length }); } res.json({ results }); }); app.listen(8080); ``` **TypeScript** ```typescript import express, { Request, Response } from "express"; import crypto from "node:crypto"; interface DeliveryEnvelope { index_id: string; index_name: string; payload_type: "values" | "weightings" | "holdings" | "changes" | "corporate_actions" | "tracker_legacy"; time: string; generated_at: string; records?: Record[]; content?: string; // tracker_legacy only: an already-rendered file body filename?: string; } type DeliveryBody = DeliveryEnvelope | { generated_at: string; deliveries: DeliveryEnvelope[] }; const app = express(); app.use(express.json({ limit: "50mb" })); app.post("/hooks/indexone", async (req: Request, res: Response) => { const supplied = Buffer.from(req.get("x-shared-secret") ?? ""); const expected = Buffer.from(process.env.INDEXONE_SHARED_SECRET!); if (supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) { return res.sendStatus(401); } const body = req.body as DeliveryBody; const envelopes = "deliveries" in body ? body.deliveries : [body]; const results = []; for (const envelope of envelopes) { const key = `${envelope.index_id}:${envelope.payload_type}:${envelope.time}`; if (await alreadySeen(key)) { results.push({ key, status: "duplicate" }); continue; } await storeRaw(key, envelope); await enqueueProcessing(key, envelope); results.push({ key, status: "accepted" }); } res.json({ results }); }); ``` > **Delivery webhooks are not signed** > > There is no HMAC or signature header on delivery webhooks today, so a receiver cannot cryptographically > verify that a request came from Index One. Signing is documented as *planned*. Until it ships, treat the endpoint > as unauthenticated by default and compensate: require a shared secret header, restrict by source IP if you can, > and — the strongest control — treat the webhook as a *notification* and re-read the authoritative data with > `GET /query` before acting on it. > **Return a 2xx, or you will be retried** > > A non-2xx response is a failure on the sender's side. 408, 429 and 5xx are retried — by default twice more > after the first attempt — so a 500 from your handler produces the same request again seconds later, which is one > more reason to be idempotent. A 4xx is not retried at all: the send is recorded as failed and the sender's next > run is the next chance you get. ## SFTP pickup The pattern that survives contact with reality: list, filter by the agreed filename shape, move each file out of the drop directory before parsing it, and record what you took. ```python import csv, io, posixpath import paramiko DROP = "/incoming/indices" DONE = "/incoming/indices/processed" transport = paramiko.Transport(("sftp.example.com", 22)) transport.connect(username="you", pkey=paramiko.RSAKey.from_private_key_file("id_rsa")) sftp = paramiko.SFTPClient.from_transport(transport) for name in sorted(sftp.listdir(DROP)): if not name.endswith(".csv"): continue src = posixpath.join(DROP, name) # Move first. A file still in DROP after a crash gets retried; a file # parsed in place and then lost does not. dst = posixpath.join(DONE, name) sftp.rename(src, dst) with sftp.open(dst) as fh: rows = list(csv.DictReader(io.TextIOWrapper(fh, encoding="utf-8"))) process(name, rows) ``` > **Partial files** > > Reading a file the moment it appears can catch it mid-upload. Either agree an atomic convention with the > sender — upload to a temp name and rename — or wait until a file's size and mtime are stable across two polls > before touching it. ## Idempotency and replay Assume every delivery can arrive more than once and that some will not arrive at all. Neither is exotic: a retry after a timeout produces a duplicate, and a receiver that was down during a send produces a gap. 1. **Derive a key.** `(index_id, payload_type, time)` identifies a snapshot. Store it and reject repeats — the same file processed twice is worse than one processed late. Do not key on `generated_at`; it is different on every resend of the same data. 2. **Keep the raw payload.** Store the bytes before parsing. When a downstream mapping turns out to be wrong you can reprocess without asking for a resend. 3. **Detect gaps, do not just log them.** For a `values` feed you know which sessions to expect from the index's `exchange_calendar`. A missing session should raise an alarm, not sit in a log. 4. **Backfill from the API, not from a resend.** `GET /query` on `index-values-eod`, `index-holdings` or `index-weightings` is authoritative and available immediately. Asking the sender to re-run a delivery is slower and gives you the same data. 5. **Watch for restatements.** A corrected historical row will not be pushed to you — event triggers ignore rows older than about three hours. Periodically re-pull a trailing window and compare. ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-values-eod" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=descending" \ --data-urlencode "limit=20" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` > **There is no endpoint that returns a delivery's artifacts** > > Once a delivery has produced a file, that file only exists where it was sent — the mailbox, the SFTP > directory, the object key, the webhook body. There is no route to fetch it back, and > `GET /deliveries/{id}/executions` tells you that a run happened, not what it contained. An artifact-retrieval > endpoint is documented as *planned*. Until then, the receiver is the system of record for what was delivered, and > `GET /query` is the system of record for what the index says — which is why keeping the raw payload matters. ## Checking what the sender saw If a file did not arrive, the sender can check that delivery's run history. This route takes the **delivery** id — the `dlv_` one. (`GET /deliveries/{id}`, which lists a team's deliveries, is the odd one out: that `{id}` is a team id.) ```bash curl "https://api.indexone.io/deliveries/dlv_YOUR_DELIVERY_ID/executions" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` The thing to know before reading it: **a failed send is recorded as a warning, not an error.** That is deliberate — the coordinator parks a live execution group permanently once it errors, so treating a transient failure as an error would silently stop the delivery for good. The consequence is that a run marked complete does not mean your file arrived. Three outcomes tell you where the problem is. A send reporting `{"sent": false, "reason": "sends are disabled in backtest mode"}` was a simulation — nothing was ever going to arrive. A run in a warning state means at least one target was refused: the send operation's output carries a per-target `results` list — `[{index_id, target, ok, attempts, detail}]` — naming which indices failed and why, and one index failing does not stop the others, so a partially delivered run is a normal thing to see. A send recorded with `sent: true` against your endpoint means the file left; look at your own intake next. > **Stored executions rarely keep the per-target rows** > > The executor strips `results` from the record it persists, so the run history usually gives you the run's > state and timing but not which index failed. The reliable way to get per-index detail is to run the delivery once > on demand — the console's **Test delivery** button, which posts to `/execute` and returns the operation output > inline. - [Set up a delivery](/docs/guides/deliveries-setup) — The sender side: channels, triggers and payload types. - [Pull live index data](/docs/guides/pull-index-data) — The authoritative read path for backfills and verification. - [Corporate actions](/docs/guides/corporate-actions) — Restatements, and why they never push. - [Deliveries reference](/docs/reference/deliveries) — Routes and fields in full. --- # Bring your own data > Upload a dataset and drive an index from it. A dataset is data you bring: an ESG score file, a proprietary signal, a list of constituents your research team maintains, a per-account exclusion list. Once uploaded it gets a `dst_` id and becomes readable from inside a workflow like any other data source. Two things to get right. Uploading is a three-call flow, not a single multipart POST. And once the data is up, inspect it before writing a filter — column names and the actual set of values in a column are things to discover, not assume. ## Uploading The file never passes through the API. You create a dataset record, ask it for a presigned S3 url, `PUT` the bytes straight to S3, and then tell the dataset to apply what you uploaded. Four HTTP calls, three of them to Index One. 1. **Create the dataset record** Returns the `dst_` id. `get_presigned_url` asks for the upload flow to be prepared. ```bash curl -X POST https://api.indexone.io/dataset \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "ESG scores", "description": "Monthly internal ESG ratings by ticker", "team_id": "YOUR_TEAM_ID", "get_presigned_url": true }' ``` 2. **Request a presigned url** Per file. `file_type` and `file_name` describe what you are about to upload. ```bash curl -X POST https://api.indexone.io/dataset/dst_9KcQm2/presigned_url \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "file_type": "csv", "file_name": "esg_2026_07.csv" }' ``` 3. **PUT the file to S3** Directly to the returned url. No Index One headers — the signature is in the url, and adding an `Authorization` header will break it. ```bash curl -X PUT "https://…s3….amazonaws.com/…?X-Amz-Signature=…" \ --upload-file esg_2026_07.csv ``` 4. **Apply the mutation** `append` adds rows, `replace` swaps the contents, `delete` removes matching rows. `mutation_data_url` points at what you just uploaded. ```bash curl -X POST https://api.indexone.io/dataset/dst_9KcQm2/mutation \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "mutation": "append", "mutation_data_url": "s3://…/esg_2026_07.csv" }' ``` **Python** ```python import requests BASE = "https://api.indexone.io" HEADERS = {"x-api-key": "YOUR_API_KEY", "Authorization": "YOUR_ID_TOKEN"} def upload(path, name, description, team_id, mutation="append"): created = requests.post( f"{BASE}/dataset", headers=HEADERS, json={"name": name, "description": description, "team_id": team_id, "get_presigned_url": True}, ) created.raise_for_status() dataset_id = created.json()["dataset_id"] presigned = requests.post( f"{BASE}/dataset/{dataset_id}/presigned_url", headers=HEADERS, json={"file_type": "csv", "file_name": path.split("/")[-1]}, ) presigned.raise_for_status() slot = presigned.json() # Straight to S3 — no Index One headers on this request. with open(path, "rb") as fh: requests.put(slot["url"], data=fh).raise_for_status() applied = requests.post( f"{BASE}/dataset/{dataset_id}/mutation", headers=HEADERS, json={"mutation": mutation, "mutation_data_url": slot["mutation_data_url"]}, ) applied.raise_for_status() return dataset_id ``` **JavaScript** ```javascript const BASE = "https://api.indexone.io"; const HEADERS = { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN", "Content-Type": "application/json", }; async function upload(file, { name, description, teamId, mutation = "append" }) { const created = await fetch(`${BASE}/dataset`, { method: "POST", headers: HEADERS, body: JSON.stringify({ name, description, team_id: teamId, get_presigned_url: true }), }).then((r) => r.json()); const slot = await fetch(`${BASE}/dataset/${created.dataset_id}/presigned_url`, { method: "POST", headers: HEADERS, body: JSON.stringify({ file_type: "csv", file_name: file.name }), }).then((r) => r.json()); await fetch(slot.url, { method: "PUT", body: file }); await fetch(`${BASE}/dataset/${created.dataset_id}/mutation`, { method: "POST", headers: HEADERS, body: JSON.stringify({ mutation, mutation_data_url: slot.mutation_data_url }), }); return created.dataset_id; } ``` **cURL** ```bash # 1. create curl -X POST https://api.indexone.io/dataset -H "x-api-key: KEY" -H "Authorization: TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"ESG scores","description":"…","team_id":"TEAM","get_presigned_url":true}' # 2. presigned url curl -X POST https://api.indexone.io/dataset/dst_9KcQm2/presigned_url \ -H "x-api-key: KEY" -H "Authorization: TOKEN" -H "Content-Type: application/json" \ -d '{"file_type":"csv","file_name":"esg.csv"}' # 3. straight to S3 curl -X PUT "PRESIGNED_URL" --upload-file esg.csv # 4. apply curl -X POST https://api.indexone.io/dataset/dst_9KcQm2/mutation \ -H "x-api-key: KEY" -H "Authorization: TOKEN" -H "Content-Type: application/json" \ -d '{"mutation":"append","mutation_data_url":"s3://…/esg.csv"}' ``` > **Nothing changes until the mutation call** > > A successful `PUT` to S3 means the bytes are stored, not that the dataset has them. Skipping step four > leaves the dataset exactly as it was, with no error anywhere. If a workflow is reading stale data, check that the > mutation ran. ## Reading it back ```bash curl "https://api.indexone.io/dataset/dst_9KcQm2?parsed=true" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` `parsed=true` returns the parsed rows rather than the raw file. From an agent, `inspect_dataset` is better: `view: "schema"` gives columns and dtypes, `view: "sample"` a few rows, `view: "unique"` with a `column` the distinct values in it. That last one is the fix for the most common failure mode — filtering on `"Technology"` when the file says `"Information Technology"`, and getting an empty universe. ### `inspect_dataset` Inspect a team dataset's real data. view='schema' (columns+dtypes), 'sample' (rows), 'shape', 'unique' (distinct values of 'column'), or 'stats' (per-column distribution summary). Use before filtering on it. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | | | `view` | "schema" \\| "sample" \\| "shape" \\| "unique" \\| "stats" | no | | | `column` | string | no | Required when view='unique'. | | `limit` | integer | no | | | `sample_rows` | integer | no | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "inspect_dataset", "arguments": { "dataset_id": "dst_9KcQm2", "view": "schema" } } }' ``` > **No list, patch or delete routes** > > `GET /dataset` (list all), `PATCH /dataset/{id}` and `DELETE /dataset/{id}` do not exist. Listing is > available in the console and through the MCP `list_datasets` tool; editing and removing content both go through > `POST /dataset/{id}/mutation` — `replace` to swap the contents wholesale, `delete` to remove rows. All three > routes are documented as *planned* in the [datasets reference](/docs/reference/datasets). ## Using it in a workflow There are two shapes, and which one you have depends on whether your file carries dates. ### A static list: trigger + load_dataset A file with no time dimension — just symbols, and perhaps scores. The schedule comes from a cron trigger, and `load_dataset` reads the file each time it fires. _Symbol-only dataset: an annual cron trigger reads the file and equally weights whatever is in it._ - `trigger` — **trigger** - `load_ds` — **load_dataset** ← `trigger` - `map_ids` — **map_identifiers** ← `load_ds` - `weights` — **create_index_weighting** ← `map_ids` - `holdings` — **create_index_holdings** ← `weights` 1. A cron trigger fires annually, aligned to the preceding XNYS session close. 2. load_dataset reads the dataset. `dataset_id` is required — the console fills it in when you pick a dataset. 3. map_identifiers turns the symbol column into Index One ids, which is what every downstream index operation expects. 4. create_index_weighting applies equal weights, create_index_holdings converts them to share counts. ```json { "name": "Dataset Index (Symbol)", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "trigger", "operation": "trigger", "parameters": { "cron": "0 0 1 1 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "load_ds", "operation": "load_dataset", "parameters": {}, "input": [ { "$ref": "trigger" } ] }, { "id": "map_ids", "operation": "map_identifiers", "parameters": { "data": { "$ref": "load_ds.output" }, "source_column": "symbol", "target_column": "id" }, "input": [ { "$ref": "load_ds" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "map_ids.output" }, "weighting_type": "equal", "id_attribute": "id" }, "input": [ { "$ref": "map_ids" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` `load_dataset` also takes `filters` (the standard row-level operators), `attributes` for column projection, and an `as_of` block — `{time_column, time, group_by}` — which selects the latest row per group at or before a cut-off. `as_of.time` defaults to `context.request_context.time`, so a file that accumulates monthly snapshots can be read point-in-time in a backtest without any extra wiring: ```json { "id": "load_scores", "operation": "load_dataset", "parameters": { "dataset_id": "dst_9KcQm2", "as_of": { "time_column": "date", "group_by": "symbol" }, "attributes": ["date", "symbol", "esg_score"] }, "input": [{ "$ref": "trigger" }] } ``` ### A dated file: dataset_trigger If your file has a date column, it can define the rebalance schedule itself. `dataset_trigger` fires once per distinct timestamp in `time_column`, and — like `index_event_trigger` — the trigger *is* the data source: downstream operations read `dataset_trigger.output.data` rather than loading the file again. _Date + symbol dataset: each dated row group is its own rebalance._ - `dataset_trigger` — **dataset_trigger** - `map_ids` — **map_identifiers** ← `dataset_trigger` - `weights` — **create_index_weighting** ← `map_ids` - `holdings` — **create_index_holdings** ← `weights` 1. dataset_trigger fires at every distinct value in the `date` column. In a backtest that replays the whole file; in live mode a dataset update can wake it. 2. The alignment settings put each fire on a real trading session — a bare date means that session’s CLOSE. Without them the trigger fires at 00:00, which anchors the rebalance to the PREVIOUS session’s index value while pricing at this session’s close, so the rebalance day’s return is lost. Set `align_time: false` to keep a time your file already states and only snap the session date. 3. `mode: "incremental"` passes only the rows matching the fired time — the constituents for that rebalance. 4. map_identifiers, then equal weighting, then holdings. No cron anywhere: the file is the schedule. ```json { "name": "Dataset Index (Date + Symbol)", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "dataset_trigger", "operation": "dataset_trigger", "parameters": { "time_column": "date", "mode": "incremental", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "map_ids", "operation": "map_identifiers", "parameters": { "data": { "$ref": "dataset_trigger.output.data" }, "source_column": "symbol", "target_column": "id" }, "input": [ { "$ref": "dataset_trigger" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "map_ids.output" }, "weighting_type": "equal", "id_attribute": "id" }, "input": [ { "$ref": "map_ids" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` | `mode` | What the trigger passes downstream | | --- | --- | | `incremental` | Only the rows matching the fired timestamp. The right choice for a constituents file. | | `full` | All rows up to the current time. Use when the step needs history, not just today. | | `all` | The entire dataset regardless of time. Beware — this leaks future rows into a backtest. | > **dataset_trigger requires dataset_id** > > Unlike `load_dataset`, which the console can populate implicitly, `dataset_trigger` will not run without > an explicit `dataset_id` parameter. And `mode: "all"` in a backtest hands every historical step the whole file, > including rows dated after that step — a look-ahead bug that produces a suspiciously good result. ## Mapping identifiers Your file almost certainly keys on tickers. Index operations key on Index One ids. `map_identifiers` bridges the two using the securities cross-section: by default it matches the `symbol` column of your data against `symbol` in the reference and writes the reference's `id` into an `id` column. All four sides are configurable — `data_source_column`, `reference_source_column`, `reference_target_column` and `output_column` — if your file keys on something else, or if you want FIGIs out rather than ids. Platform symbols are venue-suffixed — `AAPL.US`, `VOD.LN`, `7203.JP` — and an id is the security's composite FIGI. Resolution walks the platform symbol, then common vendor spellings, then the id itself, taking the first hit, so bare and vendor-style tickers resolve too. The full suffix table is in [Symbols & identifiers](/docs/start/symbols). Rows that do not match come through without an id. A delisted ticker, a renamed one, or a non-US ticker with no suffix to disambiguate it will all silently drop out at the weighting step. Preview the mapping and compare the row count before and after. > **Check the mapping before you check the index** > > Run the trigger and `map_identifiers` alone through `POST /execute` (or MCP `run_workflow`, then > `inspect_run`). If 480 of your 500 symbols mapped, you want to know that here — not from an index that quietly > holds 480 names. ## Keeping it fed - A file that drives a live index needs to be updated before the next rebalance fires, not after. Align your upload schedule with the trigger cron, with margin. - `append` on a dated file keeps the history and lets `as_of` do point-in-time lookups. `replace` destroys it — after which a backtest can only see the current snapshot at every historical step. - Uploading a new snapshot in live mode can wake a `dataset_trigger` through the dataset-update event, so a corrective re-upload may cause a rebalance. Check before re-uploading a bad file. - [Direct indexing at scale](/docs/guides/direct-indexing) — Per-account exclusions driven from a dataset. - [Run a systematic strategy](/docs/guides/systematic-strategy) — A signal file turned into weights. - [Build and backtest an index](/docs/guides/build-and-backtest) — The graph mechanics this guide assumes. - [Datasets reference](/docs/reference/datasets) — Routes and fields in full. - [Symbols & identifiers](/docs/start/symbols) — The exchange suffix table, and what else resolves. --- # Direct indexing at scale > One base index, many per-account variants. Direct indexing is one strategy expressed as many portfolios. Each account holds the securities directly rather than a fund, which lets it deviate: this client will not hold tobacco, that one is overweight their employer's stock and needs it excluded, a third wants a mild value tilt. The architecture that works is one **base index** plus one **variant workflow per account**, where the variant reads the base's published output and applies the account's rules to it. What it is not is a thousand independent strategies. Everything specific to an account should live in data, not in a graph. ## The base index The base carries the methodology: selection, weighting scheme, reconstitution and rebalance cadence. Here it is a classic market-cap benchmark — annual reconstitution, quarterly rebalance. It runs once, no matter how many accounts track it. _The base index. Selection and weighting logic exists here exactly once._ - `0` — **trigger** - `1` — **core_securities_reference** ← `0` - `4` — **i1_core_quote** ← `1` - `5` — **filter** ← `4` - `6` — **create_index_universe** ← `5` - `00` — **trigger** - `01` — **get_index_universe** ← `00` - `02` — **i1_core_quote** ← `01` - `03` — **create_index_weighting** ← `02` - `04` — **create_index_holdings** ← `03` 1. The annual flow (ops 0–6) sets membership and writes index-universes. 2. The quarterly flow (ops 00–04) reads that universe as_of the run, prices it, weights by market cap and writes index-weightings and index-holdings. 3. Every publication on those tables is an event a variant can listen for. ```json { "name": "US 500 Index", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "0", "operation": "trigger", "parameters": { "cron": "0 0 1 1 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "1", "operation": "core_securities_reference", "parameters": { "filters": [ { "field": "mic", "operator": "in", "value": [ "XNYS", "XNAS" ] }, { "field": "security_type", "operator": "eq", "value": "Common Stock" }, { "field": "exchange_country", "operator": "eq", "value": "US" }, { "field": "domicile_country", "operator": "eq", "value": "US" } ], "attributes": [ "id" ] }, "input": [ { "$ref": "0" } ] }, { "id": "4", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "1.output#id" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "marketCap" ] }, "input": [ { "$ref": "1" } ] }, { "id": "5", "operation": "filter", "parameters": { "data": { "$ref": "4.output" }, "filters": [ { "field": "marketCap", "operator": "top_n", "value": 500 } ] }, "input": [ { "$ref": "4" } ] }, { "id": "6", "operation": "create_index_universe", "parameters": { "universe": { "$ref": "5.output#id" }, "identifier": "id" }, "input": [ { "$ref": "5" } ] }, { "id": "00", "operation": "trigger", "parameters": { "cron": "0 0 1 1,4,7,10 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "01", "operation": "get_index_universe", "parameters": { "filters": [ { "field": "time", "operator": "as_of" } ] }, "input": [ { "$ref": "00" } ] }, { "id": "02", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "01.output#security" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "splitAdjClose", "marketCap" ] }, "input": [ { "$ref": "01" } ] }, { "id": "03", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "02.output" }, "weighting_type": "proportional", "id_attribute": "id", "weight_attribute": "marketCap" }, "input": [ { "$ref": "02" } ] }, { "id": "04", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "03.output" } }, "input": [ { "$ref": "03" } ] } ] } ``` > **Deploy the base first, and only once** > > Variants reference the base by its `idx_` id, so it has to be live before any of them are. It is also the > only place expensive work happens — a screen over the full US cross-section runs once per rebalance, not once per > account. ## The account rules dataset Put every account's deviations in a single dataset, keyed by account. One file, one upload path, one place to audit — and adding an account becomes a row, not a code change. ```text account_id,symbol,rule,value ACC-10042,XOM,exclude, ACC-10042,CVX,exclude, ACC-10042,MSFT,tilt,0.5 ACC-10088,PM,exclude, ACC-10088,MO,exclude, ACC-10088,AAPL,tilt,1.4 ``` Two rule types cover most mandates. **Exclusions** remove a security outright — a concentrated position held elsewhere, a values-based screen, a restricted list. **Tilts** are multipliers on the base weight: 0.5 halves it, 1.4 raises it by 40%, and the weighting step renormalises afterwards so the book still sums to one. Upload it as a normal dataset — see [Bring your own data](/docs/guides/datasets) — and update it in place as mandates change. ## The variant workflow A variant is short, because it inherits the strategy. It listens for the base's rebalance, loads the rules for its own account, removes the exclusions and re-weights what is left. _One account variant: base weighting in, account exclusions applied, holdings out._ - `base_event` — **index_event_trigger** - `excl` — **load_dataset** ← `base_event` - `excl_ids` — **map_identifiers** ← `excl` - `keep` — **filter** ← `base_event`, `excl_ids` - `weights` — **create_index_weighting** ← `keep` - `holdings` — **create_index_holdings** ← `weights` 1. index_event_trigger fires when idx_BASE500 publishes a new weighting. Like dataset_trigger, the trigger IS the data source — output.data holds the published weighting. 2. load_dataset reads the account rules, filtered to this account_id. 3. map_identifiers turns the excluded symbols into Index One ids so they can be matched against the base weighting. 4. filter with not_in drops the excluded ids. Note that both upstream operations appear in the input array — base_event for the data, excl_ids for the $ref inside the filter value. 5. create_index_weighting re-normalises the surviving base weights (weighting_type: proportional on the weight column), and create_index_holdings turns them into share counts for this account. ```json { "name": "US 500 — account ACC-10042", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "base_event", "operation": "index_event_trigger", "parameters": { "index_id": "idx_BASE500", "event_type": "weighting" } }, { "id": "excl", "operation": "load_dataset", "parameters": { "dataset_id": "dst_account_rules", "filters": [ { "field": "account_id", "operator": "eq", "value": "ACC-10042" } ], "attributes": [ "symbol" ] }, "input": [ { "$ref": "base_event" } ] }, { "id": "excl_ids", "operation": "map_identifiers", "parameters": { "data": { "$ref": "excl.output" }, "data_source_column": "symbol", "output_column": "id" }, "input": [ { "$ref": "excl" } ] }, { "id": "keep", "operation": "filter", "parameters": { "data": { "$ref": "base_event.output.data" }, "filters": [ { "field": "id", "operator": "not_in", "value": { "$ref": "excl_ids.output#id" } } ] }, "input": [ { "$ref": "base_event" }, { "$ref": "excl_ids" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "keep.output" }, "weighting_type": "proportional", "id_attribute": "id", "weight_attribute": "weight" }, "input": [ { "$ref": "keep" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` > **Re-normalise, do not just delete** > > Dropping 4% of the index and keeping the remaining weights leaves a portfolio that is 96% invested and 4% > undefined. Passing the filtered rows back through `create_index_weighting` with > `weighting_type: "proportional"` on the `weight` column redistributes the gap across what remains, in > proportion. That is what makes the variant a complete portfolio rather than the base with holes in it. ### Adding tilts A tilt is a multiplication before the renormalisation. Join the base weighting to the account's tilt rows on `id` with `how: "left"`, use `calculate` to multiply `weight` by the tilt factor into a new column, and point `create_index_weighting` at that column instead. Accounts with no tilt row get a null factor — fill it with 1 rather than dropping the security, or a client with one tilt ends up holding one stock. ```json { "id": "tilted", "operation": "calculate", "parameters": { "data": { "$ref": "joined.output" }, "formulas": [{ "output": "tilted_weight", "op": "multiply", "a": "weight", "b": "tilt_factor" }], "drop_nulls": false }, "input": [{ "$ref": "joined" }] } ``` ## Scaling to many accounts The variant graph above is identical for every account except for one literal: the `account_id` in the `load_dataset` filter, and the workflow name. That is the property to preserve as the book grows. 1. **Template the variant.** Keep one canonical `index_parameters` object with the account id as the only substitution. Generate the rest programmatically. 2. **Provision them over REST or MCP.** `POST /workflows` creates one live index per account and returns immediately, so a provisioning script can fire them off and poll `GET /workflows/{workflow_id}` for each. The MCP path is `save_workflow` then `deploy_index(confirm=true)`. Either way each deploy backfills that account's history. 3. **Never fork the graph per account.** The moment two accounts have structurally different graphs you have two strategies to maintain. Every deviation belongs in the rules dataset. 4. **Let the base fan out.** All variants listen to the same `index_event_trigger` on the base, so one base rebalance wakes all of them. Nothing has to be scheduled per account. 5. **Group the long tail.** Accounts that share a rule set can share one variant rather than getting one each. Model portfolios rather than per-account graphs where the mandate allows it. > **Deploy is per index, and it backfills** > > `deploy_index` runs a full backtest and persists its history before registering the schedule. For a large > onboarding batch, that is real compute per account — expect it to take time. The tool returns its ids > immediately; drive the batch from `get_backtest(backtest_id)` polling. ## What to deliver per account Each variant is a live index with its own `idx_` id, so it has the same read path and the same delivery options as anything else. What differs is the audience. | Recipient | Payload | Trigger | Channel | | --- | --- | --- | --- | | Trading / OMS | `changes` | `index_event` on `weighting` | SFTP or webhook — the adds, drops and weight moves to trade | | Custodian / ops | `holdings` | `index_event` on `holdings` | SFTP, one file per account | | Client reporting | `values` | `cron`, monthly | Email — the level series for the period | | Compliance | `weightings` | `index_event` on `weighting` | Webhook, for automated restricted-list checks | A delivery accepts `index_ids` as a list, so a single delivery can cover a group of accounts that share a destination. Set `per_index: true` on a webhook so the receiver gets one request per account rather than one combined body, and put the index id in `filename_template` for SFTP so files do not collide. Details in [Set up a delivery](/docs/guides/deliveries-setup). ## Operational notes - A corporate action on a base constituent affects every variant holding it. The engine handles shares and divisor per index — see [Corporate actions](/docs/guides/corporate-actions) — but the resulting trades land in every account. - Adding an exclusion mid-quarter does nothing until the variant next runs, because it is triggered by the base. If a restriction has to take effect immediately, that is a manual trigger, not a data change. - Reconcile variants against the base periodically: pull each variant's latest holdings and check that the excluded ids are genuinely absent and the weights sum to one. - Watch out for accounts whose exclusions remove a large fraction of the index. Renormalisation will still produce a valid portfolio, but its tracking error against the base may be outside what the mandate assumes. - [Bring your own data](/docs/guides/datasets) — Uploading and maintaining the account rules dataset. - [Publish a benchmark](/docs/guides/benchmarks) — Deploying the base index the variants track. - [Set up a delivery](/docs/guides/deliveries-setup) — Per-account files to trading, custody and reporting. - [Build an index with an agent](/docs/guides/agent-workflow) — Scripting the provisioning through MCP. --- # Run a systematic strategy > Signals to weights to holdings to deliveries. A systematic strategy is a signal turned into a portfolio on a schedule, repeatedly and without discretion. The platform's job is the "repeatedly and without discretion" part: the same graph runs at every rebalance, sees only the data that existed at that moment, and produces share counts a trading system can act on. This guide follows one strategy end to end — raw data, factor construction, cross-sectional scoring, selection, weighting, cadence, and getting the resulting orders to an OMS. ## The full graph `multifactor_100` is a four-factor strategy: twelve-month momentum, 60-day volatility, return on equity and debt-to-equity, combined into a composite score and used both to select the top 100 names and to weight them. _Multifactor 100 — price factors and fundamental factors computed separately, joined, standardised, combined._ - `rebal_trigger` — **trigger** - `sec_ref` — **core_securities_reference** ← `rebal_trigger` - `eod_snap` — **i1_core_quote** ← `sec_ref` - `liq_filter` — **filter** ← `eod_snap` - `eod_hist` — **i1_core_eod** ← `liq_filter` - `return_12m` — **return** ← `eod_hist` - `vol_60d` — **volatility** ← `eod_hist` - `join_price_factors` — **join** ← `return_12m`, `vol_60d` - `fundamentals` — **i1_core_fundamentals** ← `liq_filter` - `calc_bs` — **calculate** ← `fundamentals` - `merge` — **join** ← `join_price_factors`, `calc_bs` - `winsorize` — **winsorize** ← `merge` - `zscore` — **z_score** ← `winsorize` - `composite` — **composite_score** ← `zscore` - `rank_composite` — **rank** ← `composite` - `top_100` — **filter** ← `rank_composite` - `universe` — **create_index_universe** ← `top_100` - `weights` — **create_index_weighting** ← `top_100` - `holdings` — **create_index_holdings** ← `weights` 1. Quarterly aligned trigger, then core_securities_reference with the sector exclusion applied at source (sector not_in Energy, Utilities). 2. A market-cap screen to the top 500 defines the estimation universe — every factor below is computed on that set. 3. i1_core_eod pulls price history bounded by the run time; return and volatility each compute a factor from it, in parallel. 4. i1_core_fundamentals pulls TTM statements as_of the run time; calculate derives roe and debt_equity from them. 5. Two joins bring the four factors onto one row per security. 6. winsorize caps the tails at the 2nd and 98th percentiles, then z_score standardises each factor cross-sectionally. 7. composite_score combines them with signed weights, rank orders by the result, filter takes the top 100. 8. create_index_universe records the selection; create_index_weighting weights proportionally by composite score; create_index_holdings produces shares and divisor. ```json { "name": "Multifactor 100", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "rebal_trigger", "operation": "trigger", "parameters": { "cron": "0 0 1 1,4,7,10 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "sec_ref", "operation": "core_securities_reference", "parameters": { "filters": [ { "field": "mic", "operator": "in", "value": [ "XNYS", "XNAS" ] }, { "field": "security_type", "operator": "eq", "value": "Common Stock" }, { "field": "exchange_country", "operator": "eq", "value": "US" }, { "field": "domicile_country", "operator": "eq", "value": "US" }, { "field": "is_fund", "operator": "eq", "value": false }, { "field": "sector", "operator": "not_in", "value": [ "Energy", "Utilities" ] } ], "attributes": [ "id", "sector" ] }, "input": [ { "$ref": "rebal_trigger" } ] }, { "id": "eod_snap", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "sec_ref.output#id" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "splitAdjClose", "marketCap", "volume" ] }, "input": [ { "$ref": "sec_ref" } ] }, { "id": "liq_filter", "operation": "filter", "parameters": { "data": { "$ref": "eod_snap.output" }, "filters": [ { "field": "marketCap", "operator": "top_n", "value": 500 } ] }, "input": [ { "$ref": "eod_snap" } ] }, { "id": "eod_hist", "operation": "i1_core_eod", "parameters": { "filters": [ { "field": "date", "operator": "lte", "value": { "$ref": "context.request_context.time" } }, { "field": "id", "operator": "in", "value": { "$ref": "liq_filter.output#id" } } ], "attributes": [ "date", "id", "splitAdjClose" ] }, "input": [ { "$ref": "liq_filter" } ] }, { "id": "return_12m", "operation": "return", "parameters": { "data": { "$ref": "eod_hist.output" }, "price_column": "splitAdjClose", "time_column": "date", "group_by_column": "id", "window": 252, "filter_time": { "$ref": "context.request_context.time" }, "output_column_name": "momentum_12m" }, "input": [ { "$ref": "eod_hist" } ] }, { "id": "vol_60d", "operation": "volatility", "parameters": { "data": { "$ref": "eod_hist.output" }, "price_column": "splitAdjClose", "time_column": "date", "group_by_column": "id", "trailing_periods": 60, "annualized": true, "filter_time": { "$ref": "context.request_context.time" }, "output_column_name": "volatility_60d" }, "input": [ { "$ref": "eod_hist" } ] }, { "id": "join_price_factors", "operation": "join", "parameters": { "data_left": { "$ref": "return_12m.output" }, "data_right": { "$ref": "vol_60d.output" }, "on": [ "id" ], "how": "inner", "suffix": "_vol" }, "input": [ { "$ref": "return_12m" }, { "$ref": "vol_60d" } ] }, { "id": "fundamentals", "operation": "i1_core_fundamentals", "parameters": { "storage_type": "ttm", "filters": [ { "field": "id", "operator": "in", "value": { "$ref": "liq_filter.output#id" } }, { "field": "date", "operator": "as_of", "value": { "$ref": "context.request_context.time" } } ], "attributes": [ "id", "revenue", "netIncome", "totalStockholdersEquity", "totalDebt" ] }, "input": [ { "$ref": "liq_filter" } ] }, { "id": "calc_bs", "operation": "calculate", "parameters": { "data": { "$ref": "fundamentals.output" }, "formulas": [ { "output": "roe", "op": "divide", "a": "netIncome", "b": "totalStockholdersEquity" }, { "output": "debt_equity", "op": "divide", "a": "totalDebt", "b": "totalStockholdersEquity" } ], "drop_nulls": true }, "input": [ { "$ref": "fundamentals" } ] }, { "id": "merge", "operation": "join", "parameters": { "data_left": { "$ref": "join_price_factors.output" }, "data_right": { "$ref": "calc_bs.output" }, "on": [ "id" ], "how": "inner", "suffix": "_bs" }, "input": [ { "$ref": "join_price_factors" }, { "$ref": "calc_bs" } ] }, { "id": "winsorize", "operation": "winsorize", "parameters": { "data": { "$ref": "merge.output" }, "columns": [ "momentum_12m", "volatility_60d", "roe", "debt_equity" ], "lower": 0.02, "upper": 0.98 }, "input": [ { "$ref": "merge" } ] }, { "id": "zscore", "operation": "z_score", "parameters": { "data": { "$ref": "winsorize.output" }, "columns": [ "momentum_12m", "volatility_60d", "roe", "debt_equity" ], "suffix": "_z", "drop_nulls": true }, "input": [ { "$ref": "winsorize" } ] }, { "id": "composite", "operation": "composite_score", "parameters": { "data": { "$ref": "zscore.output" }, "factors": [ { "column": "momentum_12m_z", "weight": 0.3 }, { "column": "volatility_60d_z", "weight": -0.2 }, { "column": "roe_z", "weight": 0.3 }, { "column": "debt_equity_z", "weight": -0.2 } ], "output": "composite_score", "method": "weighted_average" }, "input": [ { "$ref": "zscore" } ] }, { "id": "rank_composite", "operation": "rank", "parameters": { "data": { "$ref": "composite.output" }, "column": "composite_score", "output": "score_rank", "method": "ordinal", "descending": true }, "input": [ { "$ref": "composite" } ] }, { "id": "top_100", "operation": "filter", "parameters": { "data": { "$ref": "rank_composite.output" }, "filters": [ { "field": "score_rank", "operator": "lte", "value": 100 } ] }, "input": [ { "$ref": "rank_composite" } ] }, { "id": "universe", "operation": "create_index_universe", "parameters": { "universe": { "$ref": "top_100.output#id" }, "identifier": "id" }, "input": [ { "$ref": "top_100" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "top_100.output" }, "weighting_type": "proportional", "id_attribute": "id", "weight_attribute": "composite_score" }, "input": [ { "$ref": "top_100" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` ## Where the signal comes from Three sources, and they wire in differently. | Source | Operation | Note | | --- | --- | --- | | Computed from prices | `return`, `volatility`, `max_drawdown`, `sharpe_ratio`, `tracking_error` | Fed by `i1_core_eod` history; always bound the date filter to the run time. | | Computed from statements | `i1_core_fundamentals` + `calculate` | `storage_type` picks quarterly, annual or `ttm`; the `date` filter uses `as_of` for point-in-time. | | Your own signal | `load_dataset` or `dataset_trigger` | Upload it, then join on `id` after `map_identifiers`. See [Bring your own data](/docs/guides/datasets). | | Fitted model | `model_fit` + `model_predict` | Train on a Polars frame and append predictions as a column, which then feeds the ranking like any other factor. | > **Point-in-time is your responsibility** > > The executor replays the graph at each historical trigger, but it cannot know which filters you meant to be > time-bounded. `{"field": "date", "operator": "lte", "value": {"$ref": "context.request_context.time"}}` on price > history and `as_of` on fundamentals are what keep a backtest honest. Fundamentals are the sharper edge: a > quarter's statements are not public on the quarter-end date, and `as_of` on the reported date is the correct > selector. ## Making factors comparable The three operations between the joins and the composite are the ones that make a multi-factor score mean anything. Skipping them is the most common reason a plausible strategy backtests badly. ```json { "id": "winsorize", "operation": "winsorize", "parameters": { "data": { "$ref": "merge.output" }, "columns": ["momentum_12m", "volatility_60d", "roe", "debt_equity"], "lower": 0.02, "upper": 0.98 }, "input": [{ "$ref": "merge" }] }, { "id": "zscore", "operation": "z_score", "parameters": { "data": { "$ref": "winsorize.output" }, "columns": ["momentum_12m", "volatility_60d", "roe", "debt_equity"], "suffix": "_z", "drop_nulls": true }, "input": [{ "$ref": "winsorize" }] }, { "id": "composite", "operation": "composite_score", "parameters": { "data": { "$ref": "zscore.output" }, "factors": [ { "column": "momentum_12m_z", "weight": 0.3 }, { "column": "volatility_60d_z", "weight": -0.2 }, { "column": "roe_z", "weight": 0.3 }, { "column": "debt_equity_z", "weight": -0.2 } ], "output": "composite_score", "method": "weighted_average" }, "input": [{ "$ref": "zscore" }] } ``` - **Winsorize first.** One company with a near-zero equity base produces an ROE of 400. Left in, it dominates the z-score's standard deviation and flattens every other name toward zero. Capping at the 2nd and 98th percentiles removes that without dropping the row. - **Then z-score.** A 12-month return of 0.4 and an ROE of 0.18 are not on the same scale and cannot be averaged. Z-scoring puts each factor in cross-sectional standard deviations, which is what makes the weights in the next step meaningful. - **Signs go in the weights.** Volatility and leverage carry negative weights because low is good. Do not invert the factor upstream — keeping the raw factor and expressing preference in the weight means `inspect_run` still shows you a number you recognise. - **`drop_nulls` decides who survives.** A security missing one factor drops out of the composite entirely. That is usually right, but check how many you are losing; a fundamentals coverage gap can quietly shrink the estimation universe. > **Inspect the distribution, not just the columns** > > `inspect_run` with `view: "stats"` returns count, nulls, mean, std, min, quartiles and max per column. It > is the fastest way to find the factor that is skewed or dominating the composite — before the backtest tells you > by producing a strategy that is secretly a single-factor bet. ## From score to weight `multifactor_100` weights proportionally on the composite score, which ties position size directly to conviction. It is simple and transparent, but it inherits whatever the score distribution does — and a z-score composite can be negative, which is not a weight. Three alternatives, in increasing order of control: | Approach | How | Trade-off | | --- | --- | --- | | Equal weight the selection | `weighting_type: "equal"` | Score decides membership only. Robust, higher turnover in the tail. | | Tiered | `weighting_type: "tiered"` with `tiers` | Fixed weight bands by rank position — top decile 3% each, remainder split. Predictable and easy to explain. | | Optimised | `optimizer` then `weighting_type: "custom"` | Risk-aware weights with explicit constraints. More machinery, and sensitive to the covariance estimate. | `constraints` applies to any of them, after the initial weights are computed. Every capping rule is a list of rows, each picking units — a position, or a group value via `column` — and bounding them: `{scope: "security", max: 0.05}`, `{scope: "group", column: "sector", max: 0.30}`, `{scope: "top_n", n: 5, max: 0.40}` (the five largest combined), `{scope: "above", threshold: 0.05, max: 0.40}` (UCITS 5/40). Freed weight is redistributed to the names that still have room, and a bound no book can satisfy is an error rather than a quietly non-compliant index. ### Optimiser-driven weights `max_sharpe_50` shows the optimiser path: select by market cap, pull the price history for the selection, and let the optimiser choose the weights subject to constraints. _Max Sharpe 50 — selection by size, weights from a constrained quadratic program._ - `0` — **trigger** - `1` — **core_securities_reference** ← `0` - `3` — **i1_core_quote** ← `1` - `4` — **filter** ← `3` - `5` — **create_index_universe** ← `4` - `6` — **i1_core_eod** ← `4` - `7` — **optimizer** ← `6` - `8` — **create_index_weighting** ← `7` - `9` — **create_index_holdings** ← `8` 1. Selection is ordinary: reference data, quote snapshot, top 50 by market cap, universe written. 2. i1_core_eod supplies the long-format price history the optimiser estimates returns and covariance from. 3. optimizer with objective max_sharpe, long_only: true and max_weight: 0.2 solves for the weight vector. 4. create_index_weighting takes the result with weighting_type: "custom" — the optimiser's weights are used as-is, not renormalised — reading id_attribute "asset" and weight_attribute "weight" from the optimiser output. ```json { "name": "US 50 Max Sharpe", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "0", "operation": "trigger", "parameters": { "cron": "0 0 1 1 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "1", "operation": "core_securities_reference", "parameters": { "filters": [ { "field": "mic", "operator": "in", "value": [ "XNYS", "XNAS" ] }, { "field": "security_type", "operator": "eq", "value": "Common Stock" }, { "field": "exchange_country", "operator": "eq", "value": "US" }, { "field": "domicile_country", "operator": "eq", "value": "US" } ], "attributes": [ "id" ] }, "input": [ { "$ref": "0" } ] }, { "id": "3", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "1.output#id" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "marketCap" ] }, "input": [ { "$ref": "1" } ] }, { "id": "4", "operation": "filter", "parameters": { "data": { "$ref": "3.output" }, "filters": [ { "field": "marketCap", "operator": "top_n", "value": 50 } ] }, "input": [ { "$ref": "3" } ] }, { "id": "5", "operation": "create_index_universe", "parameters": { "universe": { "$ref": "4.output#id" }, "identifier": "id" }, "input": [ { "$ref": "4" } ] }, { "id": "6", "operation": "i1_core_eod", "parameters": { "filters": [ { "field": "date", "operator": "lte", "value": { "$ref": "context.request_context.time" } }, { "field": "id", "operator": "in", "value": { "$ref": "4.output#id" } } ], "attributes": [ "date", "id", "splitAdjClose", "adjClose" ] }, "input": [ { "$ref": "4" } ] }, { "id": "7", "operation": "optimizer", "parameters": { "objective": "max_sharpe", "prices": { "$ref": "6.output" }, "long_only": true, "max_weight": 0.2 }, "input": [ { "$ref": "6" } ] }, { "id": "8", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "7.output" }, "weighting_type": "custom", "id_attribute": "asset", "weight_attribute": "weight" }, "input": [ { "$ref": "7" } ] }, { "id": "9", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "8.output" } }, "input": [ { "$ref": "8" } ] } ] } ``` > **custom, not proportional, after an optimiser** > > `proportional` normalises the column you give it. An optimiser has already produced a weight vector that > satisfies its constraints; renormalising it can break `max_weight`. `custom` uses the values as-is. Note too > that the optimiser's identifier column is `asset`, not `id` — read the operation's output shape rather than > assuming. The optimizer also supports minimum variance, risk parity, tracking-error minimisation, transaction-cost optimisation and maximum return, with group constraints, target return or risk, and a current-weight vector — the last of which is what lets you penalise turnover rather than re-solving from scratch each quarter. ## Cadence and calendar alignment The rebalance schedule is a strategy parameter, not an implementation detail. Faster rebalancing tracks the signal more closely and costs more in turnover; slower is cheaper and lets the portfolio drift from the model. Backtest the cadence itself — the same graph with a monthly and a quarterly cron are two different strategies. ```json { "cron": "0 0 1 1,4,7,10 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } ``` Alignment is what makes the cadence executable. The raw cron lands on the first of the month at midnight — a date that is regularly a holiday and a time at which no prices exist. `trading_day: "preceding"` moves the run to the last session at or before it and `session_time: "close"` stamps it at that close, so every `context.request_context.time` downstream is a real market moment. `trading_day: "following"` is the other convention; pick one, document it, and do not change it on a live index — it moves every future rebalance date. If the strategy should rebalance when *your* data updates rather than on a calendar, use `dataset_trigger` instead of a cron: it fires at each distinct timestamp in the file's time column. ## Validating before it is live 1. **Preview the factor stage** Run trigger through composite_score with `POST /execute` or MCP `run_workflow`, then `inspect_run` with `view: "stats"`. Check row counts at every join — an inner join that halves the universe is a coverage problem, not a filter. 2. **Check the selection is stable** Run the preview at two nearby dates. If the top 100 barely overlap, the signal is noise and the strategy will be all turnover. 3. **Backtest the full history** `POST /simulate`, or MCP `run_backtest`. Runs beyond 29 seconds must stream over the websocket or poll a `bkt_` id — see [Build and backtest](/docs/guides/build-and-backtest). 4. **Deploy** MCP `deploy_index` with `confirm=true`. It backfills history and registers the schedule. ## Getting holdings to an OMS A live strategy has to reach a trading system. Two ways, and they suit different setups. **Push.** A delivery with `trigger.mode: "index_event"` and `event_type: "weighting"` fires the moment the rebalance publishes. `payload_type: "changes"` sends the adds, drops and weight moves — the deltas, which is what an OMS wants — while `holdings` sends the full target book. SFTP for counterparties with a file intake, webhook for anything you control. **Pull.** `GET /query` on `index-holdings` with `order=descending&limit=1` returns the current target. Use this when your trading system runs its own schedule and would rather ask than be told, or as the reconciliation check against what a delivery sent. ```json { "trigger": { "mode": "index_event", "event_type": "weighting" }, "payload_type": "changes", "history": "latest", "index_ids": ["idx_…"], "filename_template": "{index_id}_rebalance_{date}.csv", "channel": "sftp", "sftp": { "host": "sftp.example.com", "port": 22, "username": "…", "private_key": "…", "directory": "/incoming/rebalances" } } ``` > **Holdings are targets, not orders** > > The index publishes the portfolio it should hold. Turning that into orders — netting against actual > positions, applying lot sizes, splitting across venues, handling a name that is halted — is the OMS's job. Feed it > the target and the deltas; do not treat a `changes` file as an executable order list. - [Build and backtest an index](/docs/guides/build-and-backtest) — Graph mechanics and the 29-second ceiling. - [Bring your own data](/docs/guides/datasets) — Uploading a proprietary signal and joining it in. - [Set up a delivery](/docs/guides/deliveries-setup) — Getting the rebalance file out on publication. - [Operation catalog](/docs/agents/operations) — Every statistics, transformation and optimisation operation. --- # Corporate actions > What changes on a split or dividend, and how to re-pull. Corporate actions are the reason index maintenance is not just arithmetic. A stock splits four-for-one and its price falls 75% overnight; the index must not. A company pays a dividend and its price drops by roughly the amount paid; a price-return index should show that drop and a total-return index should not. The mechanism that makes both work is the same one in both cases: **holdings are share counts, and the divisor absorbs whatever would otherwise cause a jump.** ## Turning it on Corporate-action handling is part of `default_operations`, not a node you wire in. Every example workflow on this site carries the same two-line block. ```json { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} } ``` `create_index_corporate_actions_default` switches on generation with its defaults: as the index runs, it detects actions affecting its constituents, applies them to holdings and the divisor, and writes a record to the `index-corporate-actions` table. An empty object is the normal configuration. ## Why the level stays continuous An index level is, in essence, the market value of its holdings divided by a divisor. Because holdings are stored as **share counts** rather than weights, a split is handled by changing the share count — and the divisor is changed by exactly the amount that keeps the quotient the same. | | Before a 4:1 split | After | | --- | --- | --- | | Shares held | 10,000 | 40,000 | | Price | $400 | $100 | | Position value | $4,000,000 | $4,000,000 | | Divisor | unchanged in this case | unchanged | | Index level | continuous | continuous | A pure split is self-cancelling — value in equals value out, so the divisor does not have to move. The divisor earns its keep on everything that *does* change the index's market value without any investment decision having been made: a constituent entering or leaving, a share-count change, a special dividend paid out in cash. Each of those would otherwise put a step in the level series. The divisor absorbs them. This is also why weights and holdings diverge. The weighting table records what the last rebalance intended; the holdings table records shares, which prices and corporate actions move around between rebalances. Reconciling the two and finding differences is expected — see [Pull live index data](/docs/guides/pull-index-data). > **Never compute returns from a raw close series** > > Inside a workflow, factor calculations should read `splitAdjClose`, not `close`. A raw close series has a > step change at every split, which a return calculation reads as a 75% loss. The examples use > `price_column: "splitAdjClose"` for exactly this reason. ## Dividends: PR and TR A dividend is not self-cancelling. Cash leaves the company, the price drops, and what the index does about it is a *policy* choice — which is why it is declared in `value_series` rather than handled silently. | Series | Configuration | Behaviour on an ex-date | | --- | --- | --- | | `value_pr` | No dividend policy | Price return. The level falls with the price. The dividend is simply not counted. | | `value_tr` | `dividend_policy: "pro_rata"`, `default: true` | Total return. The dividend is reinvested across the index pro rata, so the level does not fall. | Both series are written to `index-values-eod` on the same rows, so a single query returns both. The one marked `default: true` is what a consumer gets when they do not name a series — in every shipped example that is the TR series. > **PR and TR are not interchangeable** > > Over a decade, the gap between them is the compounded dividend yield of the index — for a broad US equity > index, comfortably more than 20% of cumulative return. Quoting one where a counterparty expected the other is a > silent, material error. Publish the series name alongside the index id, always. ## Reading the actions table Every action applied is recorded against the index, keyed by `time`. **cURL** ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-corporate-actions" \ --data-urlencode "pk=idx_bh7fgXWJMaa3" \ --data-urlencode "order=descending" \ --data-urlencode "limit=50" \ --data-urlencode "map_symbols=true" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Python** ```python import requests HEADERS = {"x-api-key": "YOUR_API_KEY", "Authorization": "YOUR_ID_TOKEN"} response = requests.get( "https://api.indexone.io/query", headers=HEADERS, params={ "table": "index-corporate-actions", "pk": "idx_bh7fgXWJMaa3", "order": "descending", "limit": 50, "map_symbols": "true", }, ) response.raise_for_status() for action in response.json()["items"]: print(action["time"], action) ``` **JavaScript** ```javascript const params = new URLSearchParams({ table: "index-corporate-actions", pk: "idx_bh7fgXWJMaa3", order: "descending", limit: "50", map_symbols: "true", }); const response = await fetch(`https://api.indexone.io/query?${params}`, { headers: { "x-api-key": "YOUR_API_KEY", Authorization: "YOUR_ID_TOKEN" }, }); const { items } = await response.json(); console.log(items); ``` Inside a workflow the same data is available through `get_index_corporate_actions`, which takes the index `id` and the standard time filters — `as_of` for the latest row at or before a time, `gte`/`lte`/`between` for ranges, and `lte_as_of` for the latest row at or before a time *plus* everything after it. Running it as a two-node preview workflow is a quick audit of what an index has had applied. _A two-operation preview workflow that dumps an index's corporate actions for a period._ - `run` — **manual_trigger** - `actions` — **get_index_corporate_actions** ← `run` 1. manual_trigger passes automatically in preview and backtest, so it is the right trigger for an ad-hoc read. 2. get_index_corporate_actions returns the action records, here filtered to everything from the start of the year. 3. Run it with POST /execute or MCP run_workflow — nothing is persisted. ```json { "name": "Corporate-action audit (preview workflow)", "operations": [ { "id": "run", "operation": "manual_trigger", "parameters": {} }, { "id": "actions", "operation": "get_index_corporate_actions", "parameters": { "id": "idx_bh7fgXWJMaa3", "filters": [ { "field": "time", "operator": "gte", "value": "2026-01-01 00:00:00" } ] }, "input": [ { "$ref": "run" } ] } ] } ``` ```bash curl -X POST https://api.indexone.io/execute \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"operations": [ {"id": "run", "operation": "manual_trigger", "parameters": {}}, {"id": "actions", "operation": "get_index_corporate_actions", "parameters": {"id": "idx_bh7fgXWJMaa3", "filters": [{"field": "time", "operator": "gte", "value": "2026-01-01 00:00:00"}]}, "input": [{"$ref": "run"}]} ]}' ``` A delivery can also push them: `payload_type: "corporate_actions"` on a delivery sends the same records as a panel whenever they are published. ## Restatements A restatement is a change to history that was already published. It happens when an action is reported late or corrected, when a data-quality fix lands, or when a workflow bug is repaired and the affected period is recalculated. The index rewrites the affected rows in place — same partition key, same `time` — so the table is correct afterwards, but anything you cached before is now wrong. > **Restatements do not push** > > `index_event_trigger` listens on table streams and treats rows older than about three hours as > restatements — those never fire. So a corrected historical value will not generate a delivery, will not wake a > dependent index, and will not notify a webhook receiver. Anyone holding a copy has to detect the change by > re-reading. This is the single most important operational fact on this page. ### Detecting one Re-pull a trailing window on a schedule and compare it against what you stored. A window of a few weeks catches the overwhelming majority of restatements at a small fraction of the cost of re-pulling the full history. ```python import requests BASE = "https://api.indexone.io" HEADERS = {"x-api-key": "YOUR_API_KEY", "Authorization": "YOUR_ID_TOKEN"} WINDOW = 30 # sessions def fetch_window(index_id, limit=WINDOW): response = requests.get( f"{BASE}/query", headers=HEADERS, params={"table": "index-values-eod", "pk": index_id, "order": "descending", "limit": limit}, ) response.raise_for_status() return {row["time"]: row for row in response.json()["items"]} def check(index_id, stored): """stored: {time: row} as previously persisted locally.""" current = fetch_window(index_id) restated = [ t for t, row in current.items() if t in stored and any(stored[t].get(k) != v for k, v in row.items()) ] added = [t for t in current if t not in stored] if restated: # Re-pull the full history: a restatement at the far end of the window # usually means earlier points moved too. raise SystemExit(f"{index_id}: {len(restated)} restated rows — full refresh required") return added ``` ### Re-pulling after one 1. **Re-read the value series from the start** Ascending with a cursor. A restatement that changed the divisor propagates forward, so a partial refresh can leave you with a series that is internally inconsistent. ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-values-eod" --data-urlencode "pk=idx_…" \ --data-urlencode "order=ascending" --data-urlencode "limit=1000" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` 2. **Re-read holdings for the affected rebalances** Share counts and the divisor may both have moved. ```bash curl -G "https://api.indexone.io/query" \ --data-urlencode "table=index-holdings" --data-urlencode "pk=idx_…" \ --data-urlencode "order=descending" --data-urlencode "limit=8" \ -H "x-api-key: YOUR_API_KEY" -H "Authorization: YOUR_ID_TOKEN" ``` 3. **Check the actions table for the cause** A new or amended row in `index-corporate-actions` around the restated period usually explains it, and is what you cite when telling downstream consumers why numbers changed. 4. **Replace, do not merge** Overwrite your stored series with the re-pulled one. Merging by time will keep stale values for any row whose key did not change, which is most of them. > **Store what you were told, separately from what you believe** > > Keeping the raw delivered payloads alongside your processed series makes a restatement diffable: you can > show exactly which points moved and when you learned about it. Without that, "the numbers changed" is > unanswerable. ## Where to go next - [Pull live index data](/docs/guides/pull-index-data) — Reading values, holdings and the actions table. - [Consume a delivery](/docs/guides/deliveries-consume) — Idempotency and backfill on the receiving side. - [Publish a benchmark](/docs/guides/benchmarks) — Communicating a restatement to consumers. - [Core concepts](/docs/start/concepts) — Holdings, weightings and the divisor. --- # Build an index with an agent > The MCP golden path, end to end. Everything in [Build and backtest an index](/docs/guides/build-and-backtest) can be done by an agent talking to the MCP server, without a browser and without writing the JSON by hand. This guide is that same job — a US momentum index — done tool by tool. The value is not that an agent types faster. It is that the tools make the expensive mistakes unavailable: the catalog is discoverable rather than guessable, real data can be inspected before a filter is written against it, a fragment can be previewed before the whole graph is run, and the one irreversible step needs explicit confirmation. > **The rule that matters most** > > Never invent an operation name, a parameter, a dataset id, a column name or a filter value. Every one of > those is discoverable with a tool call, and a plausible guess produces a workflow that validates, runs, and is > wrong. `list_operations`, `get_operations`, `get_example`, `inspect_dataset` and `get_column_values` > exist for exactly this. ## Connect The server is remote streamable HTTP, mounted at `/mcp`, authenticated with a gateway key. Point your client at it — [Connect an agent](/docs/mcp/connect) has the configuration for Claude Desktop, Claude Code and Cursor. Once connected, `tools/list` returns 22 tools in five groups: discovery, inspection, index data, build, and ship. Only three of them write. ## The golden path 1. **Discover the building blocks** `list_operations` returns every operation with its category — call it with no arguments for the full list, or pass a category like `index_management` to narrow. This is the authoritative catalog; it is the answer to "is there an operation that does X", and the absence of something from it is meaningful. 2. **Copy real wiring from an example** `get_example` returns production-tested workflows. Pass several ids in `example_ids` and get them in one call — cheaper than one call each, and useful when the index combines patterns. For a long/short momentum index, `long_short_130_30` is the whole answer; for anything whose selection and weighting run on separate schedules, fetch `staggered_effective` alongside it. 3. **Read the exact parameter schemas** `get_operations` with the ids you intend to use. Examples show you correct wiring but not the full option set, and defaults matter — `map_identifiers` defaults to mapping `symbol` to `id`, `create_index_weighting` defaults to `equal`. Do this before writing parameters, not after a validation failure. 4. **Inspect the real data** If the index touches a dataset, `list_datasets` for the ids and `inspect_dataset` for the schema and a sample. `get_column_values` gives the distinct values in a column — the fix for filtering on `"Technology"` when the data says `"Information Technology"` and silently matching nothing. 5. **Preview a fragment** `run_workflow` runs an operation, a sub-workflow or a whole workflow in preview mode against real source data. Nothing is persisted. It returns per-operation state and an output summary — columns, shape, a small sample — which is how you discover the columns an operation actually produces rather than the ones you expected. 6. **Look at the output properly** `inspect_run` on the cached result: `view: "schema"` for columns and dtypes, `"sample"` for rows, `"stats"` for per-column count, nulls, mean, std, min, quartiles and max. `"stats"` is how you find the factor that is skewed or dominating a composite score before it distorts a backtest. 7. **Validate the whole workflow** `validate_workflow` checks the candidate `index_parameters` against the manifest and returns structured issues rather than a pass/fail. It enforces the structural rules — every flow starts with a trigger, the workflow includes `create_index_holdings`, every flow persists a result — plus the parameter schemas. 8. **Save a draft** `save_workflow` with no `workflow_id` creates a draft owned by your team; with one, it updates that workflow in place. It validates first and never changes stage. Updating a live workflow's operations triggers a live reload, which is why the tool is marked destructive. 9. **Backtest** `run_backtest` runs the historical simulation. It validates the workflow, launches the run, and immediately returns a `bkt_...` id — poll `get_backtest` until the status is `completed` or `failed`. It never creates a live index. 10. **Deploy** `deploy_index` with `confirm=true` runs a full backtest, persists the history, and registers the index for continuous scheduled calculation. It returns the pending `idx_...` and `bkt_...` ids immediately — poll `get_backtest`; the `deployed_index_id` appears there when the run completes. ## The result Following that path for a momentum brief produces this graph. Every wiring decision in it — the trailing-return chain, the universe/weighting fork, the `$ref` shapes — was copied from the catalog examples rather than derived, which is the point of step two. _US Momentum 50 — the workflow the golden path arrives at._ - `rebal_trigger` — **trigger** - `sec_ref` — **core_securities_reference** ← `rebal_trigger` - `eod_snap` — **i1_core_quote** ← `sec_ref` - `liq_filter` — **filter** ← `eod_snap` - `eod_hist` — **i1_core_eod** ← `liq_filter` - `return_12m` — **return** ← `eod_hist` - `rank_mom` — **rank** ← `return_12m` - `top_50` — **filter** ← `rank_mom` - `universe` — **create_index_universe** ← `top_50` - `weights` — **create_index_weighting** ← `top_50` - `holdings` — **create_index_holdings** ← `weights` 1. Quarterly aligned trigger to the preceding XNYS close. 2. core_securities_reference for the US common-stock cross-section, i1_core_quote for market caps, filter for the top 200 by size. 3. i1_core_eod for price history bounded by the run time, then return for a 252-session trailing return with filter_time collapsing it to one row per security. 4. rank descending, filter to the top 50. 5. create_index_universe, create_index_weighting proportional on momentum_12m, create_index_holdings. ```json { "name": "US Momentum 50", "start_time": "2019-12-28 00:00:00", "start_value": 1000, "start_divisor": 1, "timezone": "US/Eastern", "eod_time": "16:00:00", "exchange_calendar": "XNYS", "default_operations": { "create_index_value_eod_default": { "value_series": [ { "id": "value_pr" }, { "id": "value_tr", "dividend_policy": "pro_rata", "default": true } ] }, "create_index_corporate_actions_default": {} }, "operations": [ { "id": "rebal_trigger", "operation": "trigger", "parameters": { "cron": "0 0 1 1,4,7,10 *", "alignment_enabled": true, "align_time": true, "trading_calendar": "XNYS", "trading_day": "preceding", "session_time": "close" } }, { "id": "sec_ref", "operation": "core_securities_reference", "parameters": { "filters": [ { "field": "mic", "operator": "in", "value": [ "XNYS", "XNAS" ] }, { "field": "security_type", "operator": "eq", "value": "Common Stock" }, { "field": "exchange_country", "operator": "eq", "value": "US" }, { "field": "domicile_country", "operator": "eq", "value": "US" } ], "attributes": [ "id" ] }, "input": [ { "$ref": "rebal_trigger" } ] }, { "id": "eod_snap", "operation": "i1_core_quote", "parameters": { "data": { "$ref": "sec_ref.output#id" }, "date": { "$ref": "context.request_context.time" }, "attributes": [ "marketCap" ] }, "input": [ { "$ref": "sec_ref" } ] }, { "id": "liq_filter", "operation": "filter", "parameters": { "data": { "$ref": "eod_snap.output" }, "filters": [ { "field": "marketCap", "operator": "top_n", "value": 200 } ] }, "input": [ { "$ref": "eod_snap" } ] }, { "id": "eod_hist", "operation": "i1_core_eod", "parameters": { "filters": [ { "field": "date", "operator": "lte", "value": { "$ref": "context.request_context.time" } }, { "field": "id", "operator": "in", "value": { "$ref": "liq_filter.output#id" } } ], "attributes": [ "date", "id", "splitAdjClose" ] }, "input": [ { "$ref": "liq_filter" } ] }, { "id": "return_12m", "operation": "return", "parameters": { "data": { "$ref": "eod_hist.output" }, "price_column": "splitAdjClose", "time_column": "date", "group_by_column": "id", "window": 252, "filter_time": { "$ref": "context.request_context.time" }, "output_column_name": "momentum_12m" }, "input": [ { "$ref": "eod_hist" } ] }, { "id": "rank_mom", "operation": "rank", "parameters": { "data": { "$ref": "return_12m.output" }, "column": "momentum_12m", "output": "mom_rank", "method": "ordinal", "descending": true }, "input": [ { "$ref": "return_12m" } ] }, { "id": "top_50", "operation": "filter", "parameters": { "data": { "$ref": "rank_mom.output" }, "filters": [ { "field": "mom_rank", "operator": "lte", "value": 50 } ] }, "input": [ { "$ref": "rank_mom" } ] }, { "id": "universe", "operation": "create_index_universe", "parameters": { "universe": { "$ref": "top_50.output#id" }, "identifier": "id" }, "input": [ { "$ref": "top_50" } ] }, { "id": "weights", "operation": "create_index_weighting", "parameters": { "data": { "$ref": "top_50.output" }, "weighting_type": "proportional", "id_attribute": "id", "weight_attribute": "momentum_12m" }, "input": [ { "$ref": "top_50" } ] }, { "id": "holdings", "operation": "create_index_holdings", "parameters": { "weighting": { "$ref": "weights.output" } }, "input": [ { "$ref": "weights" } ] } ] } ``` ## The tools that matter ### `list_operations` List available workflow operations (id, name, description, category). Call with no args to see all + the category list; pass a category to narrow. Use this to discover real operations instead of guessing. START HERE if you have no other context: the working order is list_operations/list_examples to discover, get_example + get_operations to copy correct wiring, run_workflow to test against real data, then run_backtest to simulate and save_workflow/deploy_index to persist. The write tools validate and preview-run for you — build, don't guess. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `category` | string | no | Optional category filter, e.g. 'index_management'. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_operations", "arguments": { "category": "index_management" } } }' ``` ### `get_example` Fetch one or MORE example workflows to copy correct structure and wiring — pass several ids in 'example_ids' in a SINGLE call. Each returned example carries 'index_parameters_json' (a JSON string of the full workflow). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `example_ids` | array | no | One or more example ids to fetch in a single call. | | `example_id` | string | no | A single example id (prefer example_ids). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_example", "arguments": { "example_ids": [ "staggered_effective", "long_short_130_30" ] } } }' ``` ### `get_operations` Get the full parameter schema and output shape for specific operations. Always fetch an operation's spec before using it so parameters are correct. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `operation_ids` | array | yes | Operation ids to fetch, e.g. ['create_index_weighting']. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_operations", "arguments": { "operation_ids": [ "create_index_weighting" ] } } }' ``` ### `run_workflow` Run a single operation, sub-workflow, or full workflow in PREVIEW mode (nothing is persisted; source data is real). Returns per-operation state and an output SUMMARY (columns, shape, small sample). Use it to test that a step works and to discover the real columns an operation produces, then inspect_run / get_column_values on the result. Omit 'operations' entirely to run the CURRENT CANVAS as-is (the cheap way to inspect the existing workflow's real data). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `operations` | string | no | JSON string: array of operation objects [{id, operation, parameters, input?}]. Omit to run the current canvas unchanged. | | `note` | string | no | Optional note about what you're testing. | **Behaviour** — read-only, idempotent, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_workflow", "arguments": { "operations": "[{\"id\":\"t\",\"operation\":\"manual_trigger\",\"parameters\":{}}]", "note": "smoke test" } } }' ``` ### `validate_workflow` Validate a candidate workflow against the manifest (structure + wiring; nothing is executed). Returns structured issues. Optional early check while drafting — submit_workflow, run_backtest, deploy_index and save_workflow all validate automatically and return the same issues. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "validate_workflow", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `save_workflow` Persist a workflow for the caller's team. Without workflow_id: creates a new DRAFT workflow. With workflow_id: updates an owned workflow in place (a live workflow's changed operations trigger a live reload). Always validated AND preview-verified first (runtime problems bounce with hints; verify=false skips the preview); never changes stage. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | | `workflow_id` | string | no | Existing workflow id to update; omit to create a draft. | | `name` | string | no | Optional display name override. | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, **destructive**. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "save_workflow", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `run_backtest` Run a historical simulation of a workflow (index_parameters JSON). Validates AND runs the workflow once in preview first (structured issues + runtime errors with hints returned on failure — no separate validate_workflow or run_workflow call needed), then launches the run and immediately returns a backtest_id — poll get_backtest until status is 'completed' or 'failed'. Never creates a live index — use deploy_index. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | | `start_time` | string | no | Backtest start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to index_parameters.start_time). | | `end_time` | string | no | Backtest end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_backtest", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `deploy_index` Create a LIVE index from a saved workflow_id or inline index_parameters: validates AND preview-verifies the workflow (runtime problems bounce with hints; verify=false skips the preview), then runs a full backtest, persists its history, and registers the index for continuous scheduled calculation. Requires confirm=true. Returns the pending index_id + backtest_id immediately — poll get_index(index_id) or get_backtest(backtest_id) until the index lands on 'live' or 'failed' (deployed_index_id appears in get_backtest when done). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | no | Saved workflow id to deploy. | | `index_parameters` | string | no | JSON string of index_parameters (alternative to workflow_id). | | `start_time` | string | no | History start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | History end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `confirm` | boolean | no | Must be true to actually deploy. | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "deploy_index", "arguments": { "workflow_id": "idx_bh7fgXWJMaa3", "confirm": true } } }' ``` ## Which tools write Everything else is annotated read-only. Three tools are not, and a client should treat them differently. | Tool | Effect | Guard | | --- | --- | --- | | `run_backtest` | Computes and stores a backtest result. Creates no index. | None needed — it is reversible in the sense that nothing depends on it. | | `save_workflow` | Creates a draft, or **overwrites an existing workflow's operations**. | Marked `destructiveHint: true` so clients can prompt. Omitting `workflow_id` is always safe. | | `deploy_index` | Creates a live index with real history and a live schedule. | `confirm=true` is required. Without it, nothing happens. | > **deploy_index is a publication event** > > A deployed index has an id other people can reference and a schedule that keeps calculating after the > conversation ends. Do not deploy to check whether something works — that is what `run_workflow` and > `run_backtest` are for. If an agent is operating unattended, `deploy_index` is the call to gate behind a human. ## Where agents actually go wrong - **A `$ref` in `parameters` with no matching entry in `input`.** The reference does not create an edge, so the value is missing at run time. This is the most common wiring bug, and `validate_workflow` catches it. - **Guessed column names.** `splitAdjClose`, not `split_adj_close`. The universe table's identifier is `security`, not `id`. The optimizer outputs `asset`, not `id`. Preview and read the schema. - **Guessed filter values.** A sector string that matches nothing produces an empty universe and a workflow that validates cleanly. `get_column_values` first. - **Unbounded date filters.** A data operation reading a range without `{"$ref": "context.request_context.time"}` sees the future at every historical step. The backtest looks excellent and means nothing. - **Deploying to test.** `run_backtest` gives the same numbers without creating anything. - **Waiting on a long run.** The launch tools never block — take the `bkt_` id they return and poll `get_backtest`. ## After deployment The index-data tools cover the read side: `get_index` for metadata, `get_index_values`, `get_index_holdings`, `get_index_weightings` and `get_index_universe` for the artifacts, and `get_index_stats` for cumulative and annualised return, annualised volatility and max drawdown. Those are also the tools an agent uses on someone else's public index — `list_workflows` with `scope: "public"` finds them. Setting up a delivery is not an MCP operation; that is `POST /deliveries` or the console. See [Set up a delivery](/docs/guides/deliveries-setup). - [Connect an agent](/docs/mcp/connect) — Client configuration for the MCP server. - [MCP tool reference](/docs/mcp/tools) — All 22 tools with their schemas. - [The golden path](/docs/mcp/golden-path) — The same sequence, as a reference page. - [Build and backtest an index](/docs/guides/build-and-backtest) — The REST-side version of this guide. --- # Authentication > Sign up, sign in and refresh tokens. Cognito-backed sign-up, sign-in, token refresh and password reset. These are the only authenticated-plane routes that need just an API key. ### Start a password reset ```http POST /forgot_password ``` Emails a password-reset confirmation code. Complete the reset with `POST /forgot_password/confirm`. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/forgot_password" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com" }' ``` **Response** ```json { "CodeDeliveryDetails": { "Destination": "q***@e***.com", "DeliveryMedium": "EMAIL", "AttributeName": "email" } } ``` | Status | Meaning | | --- | --- | | 400 | Unknown user, or Cognito rate-limited the reset. The Cognito exception is uncaught, so the body is a generic gateway error. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Complete a password reset ```http POST /forgot_password/confirm ``` Sets a new password using the code emailed by `POST /forgot_password`. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | | | `password` | string | yes | The new password. | | `confirmation_code` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/forgot_password/confirm" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "password": "N3w-Passw0rd!", "confirmation_code": "482913" }' ``` **Response** ```json {} ``` | Status | Meaning | | --- | --- | | 400 | Wrong or expired code, or the new password violates the Cognito policy. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Exchange a refresh token for a new id token ```http POST /refresh_token ``` Returns a fresh id token from the refresh token issued by `POST /signin`. No new refresh token is issued — keep using the original. An expired, revoked or malformed refresh token returns **401** with `{"error": …}` — treat it as a sign-out and re-authenticate. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `refresh_token` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/refresh_token" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.refresh" }' ``` **Response** ```json { "id_token": "eyJraWQiOiJ4WGZQK0k9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiI3ZGNkMjIxZS00Y2Y0LTRjYjUtODQ1ZS0zMmVmYzE4MDNlZTYifQ.sig", "expires_in": 3600, "status": "success" } ``` | Status | Meaning | | --- | --- | | 401 | The refresh token is expired, revoked or invalid — re-authenticate with `POST /signin`. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Sign in and get tokens ```http POST /signin ``` Authenticates against Cognito using the `USER_PASSWORD_AUTH` flow and returns an id token plus a refresh token. Send `id_token` as the `Authorization` header on every authenticated request — **raw, with no `Bearer ` prefix**. It expires after `expires_in` seconds (3600 by default); use `POST /refresh_token` to get a new one without re-prompting for the password. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | Lower-cased server-side, so case does not matter. | | `password` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/signin" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "password": "S3cure-Passw0rd!" }' ``` **Response** ```json { "id_token": "eyJraWQiOiJ4WGZQK0k9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiI3ZGNkMjIxZS00Y2Y0LTRjYjUtODQ1ZS0zMmVmYzE4MDNlZTYiLCJlbWFpbCI6InF1YW50QGV4YW1wbGUuY29tIiwiZXhwIjoxNzg0NTUyMDAwfQ.sig", "refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.refresh", "expires_in": 3600 } ``` | Status | Meaning | | --- | --- | | 401 | Authentication failed. The `code` carries the Cognito exception class: `NotAuthorizedException` for bad credentials, `UserNotConfirmedException` when `POST /signup/confirm` was never called, `UserNotFoundException` for an unknown address. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Create a user account ```http POST /signup ``` Registers a new Cognito user and creates the backing user record. If the email address has a pending team invite the user joins that team; otherwise a new team is created and the user becomes its admin. A confirmation code is emailed — call `POST /signup/confirm` next. Optional profile fields (`firstname`, `lastname`, `company`, `use_case`, `industry`, `position`, `country`, `company_type`) are stored on the user record and forwarded to the CRM. The same route doubles as the marketing contact form: posting `{"hubspot_only": true, ...}` creates only a CRM contact and no user. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | Email address. Lower-cased server-side. | | `password` | string | yes | Must satisfy the Cognito password policy. | | `firstname` | string | no | | | `lastname` | string | no | | | `company` | string | no | | | `hubspot_only` | boolean | no | Create only a CRM contact, no user account. | **Request** ```bash curl -X POST "https://api.indexone.io/signup" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "password": "S3cure-Passw0rd!", "firstname": "Ada", "lastname": "Lovelace", "company": "Example Capital" }' ``` **Response** ```json "7dcd221e-4cf4-4cb5-845e-32efc1803ee6" ``` | Status | Meaning | | --- | --- | | 400 | `User already exists, please login or reset password instead.` when the email is taken, otherwise `Unknown error.` — Cognito policy violations (weak password, invalid email) are collapsed into that generic message. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Confirm a new account ```http POST /signup/confirm ``` Completes registration with the confirmation code emailed by `POST /signup`. After this the user can sign in. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | | | `confirmation_code` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/signup/confirm" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "confirmation_code": "482913" }' ``` **Response** ```json "success" ``` | Status | Meaning | | --- | --- | | 400 | Wrong or expired code, or the user is already confirmed. The Cognito exception is not caught, so the gateway surfaces a generic error body. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | --- # API keys > Create, list and revoke team API keys. Create, list and revoke the team's API Gateway keys. Keys carry the usage-plan rate limit. ### List a team's API keys ```http GET /teams/{id}/keys ``` Lists every API Gateway key registered against the team. **Key values are returned in plaintext** (the handler passes `includeValues=True`), so this response is credential material: never log it, cache it or render it outside an authenticated screen. As with key creation, a non-member gets HTTP 200 carrying `{"message": "Not authorized to perform this operation"}` instead of a 403. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Team id. | **Request** ```bash curl -X GET "https://api.indexone.io/teams/33a36e74-b36a-4a50-9a99-edeff5144a43/keys" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "k3n8qz1p42", "value": "8Kd0Wq3mZa7Yr1Xu5Tv9Bc2Ne6Lp4Hs0Gj8Fd3R", "created_at": "2026-03-11", "enabled": true }, { "id": "m7ta4bx915", "value": "2Qp6Rn8Vs1Mj4Ck7Zw0Ye5Ta3Bu9Hd6Lx2Pg4W", "created_at": "2026-06-30", "enabled": false } ] ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Create an API key ```http POST /teams/{id}/keys ``` Creates an API Gateway key for the team and attaches it to the usage plan that carries the rate limit. The plaintext key is in the `value` field of the response — but it is also retrievable later via `GET /teams/{id}/keys`, so it is not a show-once secret. The caller must be a member of the team. **A non-member gets HTTP 200** with `{"message": "Not authorized to perform this operation"}`, not a 403. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Team id. | **Request** ```bash curl -X POST "https://api.indexone.io/teams/33a36e74-b36a-4a50-9a99-edeff5144a43/keys" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "k3n8qz1p42", "value": "8Kd0Wq3mZa7Yr1Xu5Tv9Bc2Ne6Lp4Hs0Gj8Fd3R", "created_at": "2026-07-21", "enabled": true } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Revoke an API key ```http DELETE /teams/{id}/keys/{key_id} ``` Deletes the key at API Gateway, immediately invalidating it everywhere. Deleting a key that does not exist is not an error — the handler swallows the exception and returns `{}`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Team id. | | `key_id` | string | yes | The key's `id` (not its `value`). | **Request** ```bash curl -X DELETE "https://api.indexone.io/teams/33a36e74-b36a-4a50-9a99-edeff5144a43/keys/k3n8qz1p42" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "k3n8qz1p42" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | --- # Workflows > Index workflows on the new engine. The current index plane: workflows (`idx_...`) are operation DAGs executed by the coordinator. Creation over REST is still a gap — see the `planned` operations. ### List a team's workflows ```http GET /workflows ``` Lists the workflows a team owns. Added 2026-07-21, replacing `GET /workflows/{id}`, where `{id}` confusingly meant a *team* id while the same path template under PATCH, DELETE and /executions meant a *workflow* id. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `team_id` | string | yes | The owning team. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows?team_id=tem_4Kd9Xa" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "idx_bh7fgXWJMaa3", "name": "US Momentum 50", "stage": "live", "created_at": "2026-01-14 09:02:11" } ] ``` | Status | Meaning | | --- | --- | | 403 | Missing or invalid credentials. | | 404 | No such team. | ### Create a live workflow ```http POST /workflows ``` Creates a live index: validates the workflow, persists it as `stage: "pending"`, and runs the backtest and live registration in the background. Returns immediately so the call is never held against the gateway's 29-second ceiling. Poll `GET /workflows/{workflow_id}` and watch `stage`: `pending` becomes `live` once history is written and the index is registered for continuous calculation, or `failed` with a `failure_reason` if validation, execution or the empty-index check rejected it. Failed records are kept, not deleted, so the reason is readable. Pass `websocket_id` to stream progress instead of polling, the same way the console does. This replaces the current go-live path, which is `POST /simulate` with `stage: "live"` buried in `index_parameters` — a call whose name says simulation and whose effect is a production index. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `start_time` | string | yes | Datetime the index history begins at, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`. | | `exchange_calendar` | string | no | `xcals` calendar code driving trading days. | | `team_id` | string | no | Owning team. Would default to the caller's primary team. | | `operations` | array of object | yes | The DAG. Every flow must start with a trigger operation, must include `create_index_holdings`, and must persist its result. Operations reference upstream outputs with `{"$ref": ".output#column"}` and must also list that operation in `input`. | **Request** ```bash curl -X POST "https://api.indexone.io/workflows" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Example Momentum Index", "start_time": "2020-01-02 00:00:00", "exchange_calendar": "XNYS", "operations": [ { "id": "trigger_1", "operation": "trigger", "parameters": { "cron": "0 16 * * MON-FRI", "exchange_calendar": "XNYS" }, "input": [] }, { "id": "eod_1", "operation": "i1_core_eod", "parameters": {}, "input": [ { "$ref": "trigger_1" } ] }, { "id": "holdings_1", "operation": "create_index_holdings", "parameters": {}, "input": [ { "$ref": "eod_1" } ] } ] }' ``` | Status | Meaning | | --- | --- | | 403 | Missing or invalid credentials. | | 422 | The workflow failed validation. The body lists the issues. | ### Get a workflow ```http GET /workflows/{workflow_id} ``` Returns one workflow's whole record — metadata, calendar settings, stage, and its `operations` DAG. This is what you poll after `POST /workflows`: watch `stage` move from `pending` to `live`, or to `failed` with a `failure_reason`. Added 2026-07-21. Before that this path took a *team* id and returned that team's whole list, while the same path under PATCH and DELETE took a workflow id — so there was no way to fetch a single workflow at all. The list moved to `GET /workflows?team_id=`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | **Team** id — not a workflow id. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/33a36e74-b36a-4a50-9a99-edeff5144a43" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "name": "US Momentum 50", "stage": "live", "status": "live", "team_id": "tem_4Kd9Xa", "exchange_calendar": "XNYS", "timezone": "US/Eastern", "created_at": "2026-01-14 09:02:11", "updated_at": "2026-07-20 16:00:04", "operations": [] } ``` | Status | Meaning | | --- | --- | | 403 | The workflow is not in one of your teams — or does not exist. Ownership is checked before existence, so an unknown id is indistinguishable from someone else's, which keeps ids non-enumerable. | | 404 | The workflow is referenced by your team but its row is missing — a data inconsistency, not a normal outcome. A plain unknown id returns 403. | ### Update a workflow ```http PATCH /workflows/{workflow_id} ``` Partially updates a workflow row. The fields `id`, `team_id`, `stage` and `created_at` are blacklisted and silently dropped from the patch — in particular you cannot promote a draft to live this way — going live is what the planned `POST /workflows` does. When the patch touches `operations`, `updated_at` is refreshed and a `workflow_updated` trigger is posted to the coordinator so the running schedule reloads; the outcome of that notification is reported back in `trigger_notification`. The caller must belong to a team that owns the workflow, or be a site admin. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `visibility` | "private" \| "public" | no | | | `operations` | array of object | no | The full operation DAG. Replaces the existing one wholesale. | | `exchange_calendar` | string | no | | | `timezone` | string | no | | **Request** ```bash curl -X PATCH "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Example Momentum Index v2", "visibility": "public" }' ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "name": "Example Momentum Index v2", "visibility": "public", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "stage": "live", "updated_at": "2026-07-21 08:15:02" } ``` | Status | Meaning | | --- | --- | | 400 | `id required`, or `no editable fields` when the body was empty or contained only blacklisted keys. | | 401 | Missing or invalid id token. | | 403 | The caller's teams do not own this workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Delete a workflow and all its data ```http DELETE /workflows/{workflow_id} ``` Permanently deletes the workflow **and every row it produced**. This is not reversible and there is no soft-delete window. The sequence is: notify the coordinator (`workflow_deleted`) so it stops scheduling; mark the row `stage=deleting`; purge every partition of `index-holdings`, `index-values-eod`, `index-weightings`, `index-universes` and `index-corporate-actions` in parallel; delete the workflow row; unlink it from the caller's teams. The response reports how many rows were purged per table; `-1` means that table's purge raised and its data may be partially left behind. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Request** ```bash curl -X DELETE "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "deleted": "idx_bh7fgXWJMaa3", "purged": { "prod-index-holdings": 1482, "prod-index-values-eod": 1663, "prod-index-weightings": 44, "prod-index-universes": 44, "prod-index-corporate-actions": 219 }, "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_5Xn2QpLw81Zk" } } } ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | The caller's teams do not own this workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's holdings ```http GET /workflows/{workflow_id}/holdings ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today: `GET /query?table=index-holdings&pk=idx_...` (or the MCP `get_index_holdings` tool). The intent is a holdings snapshot with resolved identifiers — shares, weight, price and value per constituent for a given date — rather than the raw stored row. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `time` | string | no | Snapshot timestamp. Defaults to the latest. | | `map_symbols` | boolean | no | Resolve FIGIs to ticker symbols. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/holdings" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "time": "2026-07-18 20:00:00", "holdings": [ { "id": "BBG000B9XRY4", "symbol": "AAPL", "shares": 1204.55, "weight": 0.0731, "price": 233.18, "value": 280837.5 } ] } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow, or no holdings at that time. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's performance statistics ```http GET /workflows/{workflow_id}/stats ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today the only workflow-plane equivalent is the MCP `get_index_stats` tool. The intent is the same statistics surface (returns, volatility, Sharpe, Sortino, max drawdown, period returns) computed over an `idx_...` index's value series. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `returns` | string | no | Comma-separated windows to compute returns over. | | `volatility` | string | no | Comma-separated windows for annualized volatility. | | `sharpe` | string | no | Comma-separated windows for the Sharpe ratio. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/stats" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "returns": { "itd": 0.1843, "1y": 0.0912, "ytd": 0.0471, "30d": 0.0128 }, "volatility": { "1y": 0.1633, "ytd": 0.1502 }, "sharpe": { "itd": 0.94, "1y": 0.56 } } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's universe ```http GET /workflows/{workflow_id}/universe ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today: `GET /query?table=index-universes&pk=idx_...` (or the MCP `get_index_universe` tool). The universe is the selection-stage output: every security that passed the filters at a given reconstitution, before weighting. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `time` | string | no | Reconstitution timestamp. Defaults to the latest. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/universe" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "time": "2026-06-30 20:00:00", "universe": [ "BBG000B9XRY4", "BBG000BPH459", "BBG000BVPV84" ] } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's index values ```http GET /workflows/{workflow_id}/values ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today the equivalent read is `GET /query?table=index-values-eod&pk=idx_...` (or the MCP `get_index_values` tool). The intent is a first-class, chart-shaped read for the workflow plane: duration windows, increment down-sampling and a `[{time, value}]` payload, instead of making callers page raw DynamoDB rows through the generic query endpoint. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `duration` | string | no | Window, e.g. `1d`, `30d`, `1y`, `ytd`, `itd`. | | `increment` | string | no | Down-sampling interval, e.g. `1d`, `1w`, `1mo`, `eod`. | | `limit` | integer | no | Return only the most recent N points. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/values" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "time": "2026-07-17 20:00:00", "value": 1178.42 }, { "time": "2026-07-18 20:00:00", "value": 1184.37 } ] ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's target weightings ```http GET /workflows/{workflow_id}/weightings ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today: `GET /query?table=index-weightings&pk=idx_...` (or the MCP `get_index_weightings` tool). Weightings are the targets set at each rebalance, as distinct from holdings, which drift with prices between rebalances. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `time` | string | no | Rebalance timestamp. Defaults to the latest. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/weightings" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "time": "2026-06-30 20:00:00", "weighting": { "BBG000B9XRY4": 0.0731, "BBG000BPH459": 0.0654 } } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | --- # Executions > Run history for workflows and deliveries. Execution history for workflows and deliveries. One execution row per operation group per fire. ### List a delivery's executions ```http GET /deliveries/{id}/executions ``` Returns the delivery's entire execution history, from the same `{stage}-executions` table as workflow executions and with the same row shape — deliveries and workflows are indistinguishable to the coordinator. **Here `{id}` is the DELIVERY id.** This is how you confirm a send actually happened: a `completed` state means the send operation returned without error. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Delivery** id. | **Request** ```bash curl -X GET "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92/executions" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "index_id": "dlv_c8KtQ1rPxZ92", "execution_id": "exn_dQ4vTnRy73Lc", "state": "completed", "updated_at": "2026-07-18 20:06:41", "request_context": { "registered_time": "2026-07-18 20:05:02", "execution_time": "2026-07-18 20:06:30", "execution_mode": "live" } } ] ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 429 | API key usage-plan rate limit exceeded. | ### List a workflow's executions ```http GET /workflows/{workflow_id}/executions ``` Returns the workflow's **entire** execution history from the `{stage}-executions` table — there is no limit or cursor parameter, so for a long-running live index this response can be large. Each row is one execution group: its `state`, the `request_context` timings (`registered_time`, `execution_time`, `execution_mode`), `next_execution_time`, and `last_error` when it failed. Note that per-group durations are not stored — they are inferred by comparing a group's `execution_time` to its successor's `registered_time`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id (here it really is the workflow, not the team). | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/executions" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "index_id": "idx_bh7fgXWJMaa3", "execution_id": "exn_bhE9JoGPHwAi", "execution_group_id": "grp_2", "state": "completed", "updated_at": "2026-07-18 20:04:12", "next_execution_time": "2026-07-21 20:00:00", "request_context": { "registered_time": "2026-07-18 20:00:03", "execution_time": "2026-07-18 20:00:15", "execution_mode": "live" } } ] ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get one execution ```http GET /workflows/{workflow_id}/executions/{execution_id} ``` Returns a single execution row in full, including the resolved `operations` DAG as it was executed and each operation's output state. This is the endpoint to use when debugging a failed run — `last_error` on the list endpoint tells you *that* it broke, this one tells you *where*. The execution is returned wrapped: `{"execution": {...}}`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | | `execution_id` | string | yes | Execution id from the list endpoint. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/executions/exn_bhE9JoGPHwAi" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "execution": { "index_id": "idx_bh7fgXWJMaa3", "execution_id": "exn_bhE9JoGPHwAi", "execution_group_id": "grp_2", "state": "completed", "updated_at": "2026-07-18 20:04:12", "request_context": { "registered_time": "2026-07-18 20:00:03", "execution_time": "2026-07-18 20:00:15", "execution_mode": "live" }, "operations": [ { "id": "trigger_1", "operation": "trigger", "state": { "error": false, "suspended": false } }, { "id": "eod_1", "operation": "i1_core_eod", "state": { "error": false, "suspended": false } } ] } } ``` | Status | Meaning | | --- | --- | | 400 | `workflow id and execution_id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No execution with that id under this workflow. | | 429 | API key usage-plan rate limit exceeded. | --- # Data query > The cursor-paginated read path for index data. `GET /query` is the generic read path for all workflow-plane data: values, holdings, weightings, universes and corporate actions. ### Query workflow-plane data ```http GET /query ``` The main data-read path for `idx_...` indices. It exposes the underlying DynamoDB tables directly as a keyed query: you pick a table, give a partition key (`pk` — the workflow or delivery id) and get back rows in sort-key order. **Tables** (`table`): `index-parameters` (the workflow definition itself, no sort key), `index-holdings`, `index-values-eod`, `index-weightings`, `index-universes`, `index-corporate-actions` (all sorted by `time`), and `deliveries` (no sort key). **Narrowing the read.** `sk` pins one exact sort-key value — the fastest way to fetch a single snapshot. `attributes` is a projection: only the named top-level fields come back, which matters because holdings and universe rows are large. `order=descending` with `limit` is the idiom for "most recent N". **Paging.** `cursor` is an opaque base64-encoded DynamoDB `LastEvaluatedKey`; never parse or construct it. It is returned only when `limit` was reached and more rows exist. Without `limit` the handler pages internally and returns the whole partition in one response — safe for values, expensive for holdings. **Symbols.** `map_symbols=true` adds a `figi_symbols` map covering every identifier that appears anywhere in the payload, so you can render tickers without a second lookup. **Access control.** `pk` must be a workflow or delivery id belonging to one of the caller's teams (site admins bypass this). This is the only authorization check — there is no per-table permission. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `table` | "index-parameters" \| "index-holdings" \| "index-values-eod" \| "index-weightings" \| "index-universes" \| "index-corporate-actions" \| "deliveries" | yes | Which table to read. | | `pk` | string | yes | Partition key: the workflow (`idx_...`) or delivery (`dlv_...`) id. | | `sk` | string | no | Exact sort-key value (`time`). Ignored for tables without a sort key. | | `attributes` | string | no | Comma-separated top-level fields to project. Omit for whole rows. | | `order` | "ascending" \| "descending" | no | Sort-key direction. | | `limit` | integer | no | Maximum rows to return. Also enables cursor paging. | | `cursor` | string | no | Opaque continuation token from a previous response. | | `map_symbols` | boolean | no | Add a `figi_symbols` FIGI-to-ticker map. | **Request** ```bash curl -X GET "https://api.indexone.io/query?table=index-values-eod&pk=idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "items": [ { "id": "idx_bh7fgXWJMaa3", "time": "2026-07-18 20:00:00", "value": 1184.37 }, { "id": "idx_bh7fgXWJMaa3", "time": "2026-07-17 20:00:00", "value": 1178.42 } ], "count": 2, "cursor": "eyJpZCI6IHsiUyI6ICJpZHhfYmg3Zmd..." } ``` | Status | Meaning | | --- | --- | | 400 | `table and pk required`, or `unknown table ` when `table` is not one of the seven allowed aliases. | | 401 | Missing or invalid id token. | | 403 | `pk` is not a workflow or delivery owned by one of the caller's teams. | | 429 | API key usage-plan rate limit exceeded. | --- # Deliveries > Scheduled file delivery to email, webhook and SFTP. Scheduled distribution of index data over email, webhook, SFTP, S3 or a partner integration. A delivery is an ordinary workflow — a plain `operations` list — stored in `{stage}-deliveries` and run by the same coordinator as workflows. ### Create a delivery ```http POST /deliveries ``` Creates a delivery rule: a schedule plus a payload plus a destination. Deliveries are workflow-engine rows in their own table and are picked up by the same execution coordinator as workflows, so a delivery behaves exactly like a workflow at runtime. A delivery **is** an ordinary workflow: `operations` is a plain list of operation nodes, exactly as a workflow stores them, and nothing is expanded at run time. Two shapes cover everything: - **Standard** — `trigger -> index_panel -> send_`, where the channel is `send_email`, `send_webhook`, `send_sftp` or `send_s3`. The trigger is `index_event_trigger` (fires when a target index publishes `value_eod`, `universe`, `weighting` or `holdings`), `trigger` (cron) or `manual_trigger`. The console's one-click form writes and reads back exactly these nodes, so the workflow builder shows what actually runs. - **Partner** — `trigger -> send_` (`send_alphabot`, `send_alphathena`, `send_stratifi`, `send_refinitiv`). A partner operation is the whole flow — it reads the data, formats it the one way that partner accepts and sends it over the one transport that partner speaks — so there is no `index_panel` node; you supply only the per-index account binding, such as `ric_map` for LSEG. `operations` is required. Every operation and its parameters are listed in the live catalog at `GET /schema`. The new delivery is linked into the team's `deliveries` map and the coordinator is notified immediately, so a `live` delivery with an `index_event` trigger can fire the same day. Note the success status is **200, not 201**. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `team_id` | string | yes | Owning team. Must be a team the caller belongs to. | | `name` | string | yes | | | `description` | string | no | | | `stage` | "live" \| "draft" | no | `live` starts scheduling immediately. | | `timezone` | string | no | | | `index_id` | string | no | Single target index. Use `index_ids` for several. | | `index_ids` | array of string | no | Target indices. Both `idx_...` and legacy uuid ids are accepted. | | `operations` | array of object | yes | The delivery's operations, in execution order: `trigger -> index_panel -> send_` for a standard delivery, `trigger -> send_` for a partner one. Required. | **Request** ```bash curl -X POST "https://api.indexone.io/deliveries" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "name": "Daily values to ops", "stage": "live", "timezone": "America/New_York", "index_ids": [ "idx_bh7fgXWJMaa3" ], "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "payload_type": "values", "history": "latest", "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "filename_template": "{index_id}_{date}_values.csv" } }, { "id": "delivery_send", "operation": "send_email", "use_cache": false, "input": [ { "$ref": "delivery_payload" } ], "parameters": { "recipients": [ "ops@example.com" ], "subject": "Daily index values", "body": "Attached are today's closing values." } } ] }' ``` **Response** ```json { "id": "dlv_c8KtQ1rPxZ92", "type": "delivery", "name": "Daily values to ops", "stage": "live", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "user_id": "7dcd221e-4cf4-4cb5-845e-32efc1803ee6", "timezone": "America/New_York", "index_ids": [ "idx_bh7fgXWJMaa3" ], "created_at": "2026-07-21 08:35:02", "updated_at": "2026-07-21 08:35:02", "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_5Xn2QpLw81Zk" } } } ``` | Status | Meaning | | --- | --- | | 400 | `name required`, `operations required`, or `no team resolved for this caller`. | | 401 | Missing or invalid id token. | | 403 | Caller is not a member of `team_id`. | | 429 | API key usage-plan rate limit exceeded. | ### Retrieve files a delivery produced ```http GET /deliveries/{delivery_id}/artifacts ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today a delivery's output only leaves the system through its configured channel: email attachment, webhook body, or SFTP upload. There is no way to fetch what was sent afterwards, which makes a failed send unrecoverable without re-running the delivery, and makes pull-based integrations impossible. The intent is to list the artifacts each execution generated — filename, target index, size, generation time — with a short-lived presigned URL to download each one. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `delivery_id` | string | yes | Delivery id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `execution_id` | string | no | Restrict to one execution. Defaults to the most recent. | **Request** ```bash curl -X GET "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92/artifacts" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "artifacts": [ { "filename": "idx_bh7fgXWJMaa3_2026-07-18_values.csv", "index_id": "idx_bh7fgXWJMaa3", "execution_id": "exn_dQ4vTnRy73Lc", "created_at": "2026-07-18 20:06:35", "size_bytes": 2184, "url": "https://prod-core-i1-export.s3.eu-west-1.amazonaws.com/dlv_c8KtQ1rPxZ92/idx_bh7fgXWJMaa3_2026-07-18_values.csv?X-Amz-Expires=900" } ] } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 404 | No such delivery or execution. | | 429 | API key usage-plan rate limit exceeded. | ### List a team's deliveries ```http GET /deliveries/{id} ``` Returns the full delivery records for every delivery the team owns. **`{id}` is the TEAM id, not a delivery id** — the same convention as `GET /workflows/{id}`, and the reason `PATCH`/`DELETE` on this same path mean something different (those take the delivery id). There is no route that fetches a single delivery by its own id. Returns `[]` when the team has no deliveries. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Team** id — not a delivery id. | **Request** ```bash curl -X GET "https://api.indexone.io/deliveries/33a36e74-b36a-4a50-9a99-edeff5144a43" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "dlv_c8KtQ1rPxZ92", "type": "delivery", "name": "Daily values to ops", "stage": "live", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "index_ids": [ "idx_bh7fgXWJMaa3" ], "timezone": "America/New_York", "created_at": "2026-06-12 15:41:55", "updated_at": "2026-07-14 10:02:33", "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "payload_type": "values", "history": "latest", "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "filename_template": "{index_id}_{date}_values.csv" } }, { "id": "delivery_send", "operation": "send_email", "use_cache": false, "input": [ { "$ref": "delivery_payload" } ], "parameters": { "recipients": [ "ops@example.com" ], "subject": "Daily index values", "body": "Attached are today's closing values." } } ] }, { "id": "dlv_Q4mWs7Ld20Bk", "type": "delivery", "name": "Closing values to LSEG", "stage": "live", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "index_ids": [ "idx_bh7fgXWJMaa3" ], "timezone": "UTC", "created_at": "2026-07-02 09:12:40", "updated_at": "2026-07-02 09:12:40", "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_send", "operation": "send_refinitiv", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "ric_map": { "idx_bh7fgXWJMaa3": ".SPLTPR" } } } ] } ] ``` | Status | Meaning | | --- | --- | | 400 | `team id required`. | | 401 | Missing or invalid id token. | | 403 | Caller is not a member of that team. | | 429 | API key usage-plan rate limit exceeded. | ### Update a delivery ```http PATCH /deliveries/{id} ``` Partially updates a delivery. **Here `{id}` is the DELIVERY id**, unlike the `GET` on this same path, which takes the team id. `id`, `type`, `team_id` and `created_at` are blacklisted and dropped. Sending a field with the value `null` **removes** that attribute from the row rather than setting it to null — this is how you clear an optional config block. Every patch notifies the coordinator to reload the delivery, because any field can change execution behaviour. Pausing a delivery is done by patching `stage`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Delivery** id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `stage` | "live" \| "draft" | no | Set to `draft` to pause the delivery. | | `index_ids` | array of string | no | | | `timezone` | string | no | | | `operations` | array of object | no | Replaces the delivery's operations. Send the whole list — a partial list is stored as-is and is what will run. | **Request** ```bash curl -X PATCH "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "stage": "draft" }' ``` **Response** ```json { "id": "dlv_c8KtQ1rPxZ92", "name": "Daily values to ops", "stage": "draft", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "updated_at": "2026-07-21 08:40:19", "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_7Bz4RmKt29Qd" } } } ``` | Status | Meaning | | --- | --- | | 400 | `id required`, or `no editable fields`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 429 | API key usage-plan rate limit exceeded. | ### Delete a delivery ```http DELETE /deliveries/{id} ``` Deletes the delivery row and unlinks it from the team, after telling the coordinator to stop scheduling it. **Here `{id}` is the DELIVERY id.** Unlike deleting a workflow, this purges no data — a delivery owns no value or holdings history, only its own definition and its execution rows. Files already sent are unaffected. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Delivery** id. | **Request** ```bash curl -X DELETE "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "deleted": "dlv_c8KtQ1rPxZ92", "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_7Bz4RmKt29Qd" } } } ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 429 | API key usage-plan rate limit exceeded. | --- # Datasets > Upload and mutate your own data. Team-owned tabular data (`dst_...`) stored as parquet in S3 and consumable from a workflow via the `load_dataset` operation. ### List datasets ```http GET /dataset ``` Lists the datasets belonging to a team, newest first. The datasets table is keyed on `id` alone with no secondary index, so it cannot be queried by team. This reads the `datasets` map held on the team record — the same source the console list and the MCP `list_datasets` tool use — which is why `team_id` is required rather than inferred. Added 2026-07-21. Before that there was no collection route and callers had to read the team record themselves via `GET /teams/{id}`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `team_id` | string | no | Team to list for. Would default to the caller's primary team. | **Request** ```bash curl -X GET "https://api.indexone.io/dataset" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "updated_at": "2026-07-21 08:50:42", "row_count": 4812 } ] ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller is not a member of that team. | | 429 | API key usage-plan rate limit exceeded. | ### Create a dataset ```http POST /dataset ``` Creates an empty dataset record (`dst_...`) and reserves its S3 prefix, then links it into the team's `datasets` map. The dataset holds no data yet: upload it with `POST /dataset/{dataset_id}/presigned_url` followed by `POST /dataset/{dataset_id}/mutation`. Once populated, the dataset is readable from a workflow through the `load_dataset` operation. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `team_id` | string | no | Owning team. | | `get_presigned_url` | boolean | no | Also return an upload URL in the same call. | **Request** ```bash curl -X POST "https://api.indexone.io/dataset" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Custom ESG scores", "description": "Quarterly vendor ESG scores keyed by FIGI", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43" }' ``` **Response** ```json { "dataset": { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "description": "Quarterly vendor ESG scores keyed by FIGI", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "created_at": "2026-07-21 08:45:10", "updated_at": "2026-07-21 08:45:10", "s3_url": "s3://prod-core-i1-datasets/dst_R4mVnQ8xL27p", "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p" }, "team_data": { "id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "datasets": { "dst_R4mVnQ8xL27p": { "id": "dst_R4mVnQ8xL27p" } } } } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Get a dataset ```http GET /dataset/{dataset_id} ``` Returns the dataset record. With `parsed=true` the current parquet file is read from S3 and its rows are attached as `data` — convenient for previews, but it loads the whole dataset into the response, so avoid it for large files. Note there is no ownership check on this route: any authenticated caller who knows a `dst_...` id can read it. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `parsed` | boolean | no | Also read and return the dataset rows as `data`. | **Request** ```bash curl -X GET "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "description": "Quarterly vendor ESG scores keyed by FIGI", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "created_at": "2026-07-21 08:45:10", "updated_at": "2026-07-21 08:50:42", "s3_url": "s3://prod-core-i1-datasets/dst_R4mVnQ8xL27p", "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 404 | No such dataset. The handler indexes the DynamoDB `Item` key unguarded, so an unknown id surfaces as a `500`-class gateway error rather than a clean 404. | | 429 | API key usage-plan rate limit exceeded. | ### Update dataset metadata ```http PATCH /dataset/{dataset_id} ``` Updates a dataset's metadata. Only `name` and `description` may be changed; everything else on the record is an identifier or derived from one (`s3_url` and `url` embed the dataset id), so changing them would orphan the stored objects. The team record holds a full copy of each dataset, written once at creation. This endpoint rewrites that copy too, so a rename is reflected everywhere the team map is read. Data changes still go through `POST /dataset/{dataset_id}/mutation`. Added 2026-07-21. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | **Request** ```bash curl -X PATCH "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Custom ESG scores (v2)", "description": "Now including governance sub-scores" }' ``` **Response** ```json { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores (v2)", "description": "Now including governance sub-scores", "updated_at": "2026-07-21 09:01:00" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this dataset. | | 404 | No such dataset. | | 429 | API key usage-plan rate limit exceeded. | ### Delete a dataset ```http DELETE /dataset/{dataset_id} ``` Deletes a dataset: every S3 object under its prefix, its DynamoDB record, and its entry in the team's `datasets` map. Not reversible. Added 2026-07-21. The same purge remains available as `POST /dataset/{dataset_id}/mutation` with `{"mutation": "delete"}`, which is how deletion worked before this route existed — an overload that made a destructive action look like a data append. Prefer `DELETE`. Workflows referencing the dataset are not updated and will fail on their next run. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request** ```bash curl -X DELETE "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "dataset_id": "dst_R4mVnQ8xL27p", "s3_deleted": 12, "record_deleted": true, "reference_removed": true } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this dataset. | | 404 | No such dataset. | | 429 | API key usage-plan rate limit exceeded. | ### Apply an upload to a dataset ```http POST /dataset/{dataset_id}/mutation ``` Step two of the upload flow. Reads the file you uploaded and folds it into the dataset's current parquet. - `append` — concatenate the new rows onto the existing data. Columns are unioned, so a schema mismatch produces nulls rather than an error. - `replace` — discard the existing data and use the upload as the new dataset. - `delete` — **deletes the entire dataset**: every S3 object under its prefix, the DynamoDB record and the team link. `mutation_data_url` is ignored. This is the de-facto delete endpoint until `DELETE /dataset/{dataset_id}` exists. `mutation_data_url` is the presigned URL with its query string removed (or any `s3://` or `https://.s3.amazonaws.com/` URL). `.csv`, `.tsv` and `.parquet` are recognised by extension. On success a `dataset_update` trigger is posted so workflows consuming this dataset can react. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `mutation` | "append" \| "replace" \| "delete" | yes | | | `mutation_data_url` | string | no | URL of the uploaded file. Required for `append` and `replace`. | **Request** ```bash curl -X POST "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p/mutation" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "mutation": "append", "mutation_data_url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p/fil_7YtQ2nWx4Kp8/esg_scores_2026q2.csv" }' ``` **Response** ```json { "dataset": { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "updated_at": "2026-07-21 08:50:42", "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p" }, "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_3Kw8PnBv52Hj" } } } ``` | Status | Meaning | | --- | --- | | 400 | `mutation type not specified`, or `unsupported mutation type: `. | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 404 | No such dataset. | | 429 | API key usage-plan rate limit exceeded. | ### Get an upload URL for dataset data ```http POST /dataset/{dataset_id}/presigned_url ``` Returns a presigned S3 `PUT` URL. Step one of the two-step upload: `PUT` your CSV or parquet bytes to the returned `url` with `Content-Type` set to exactly the `file_type` you requested (a mismatch makes S3 reject the signature), then call `POST /dataset/{dataset_id}/mutation` with the URL stripped of its query string to fold the upload into the dataset. The upload lands at a unique per-file key, so an upload alone never overwrites the current data. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `file_type` | string | yes | MIME type of the upload; becomes the required `Content-Type` of the PUT. | | `file_name` | string | no | Optional filename. Defaults to a generated `fil_...` name. | **Request** ```bash curl -X POST "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p/presigned_url" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "file_type": "text/csv", "file_name": "esg_scores_2026q2.csv" }' ``` **Response** ```json { "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p/fil_7YtQ2nWx4Kp8/esg_scores_2026q2.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=abc123", "key": "dst_R4mVnQ8xL27p/fil_7YtQ2nWx4Kp8/esg_scores_2026q2.csv", "bucket": "prod-core-i1-datasets", "dataset_id": "dst_R4mVnQ8xL27p", "content_type": "text/csv" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Validate a workflow or a dataset without running it ```http POST /validate ``` Two things can be validated, told apart by whether the body carries `dataset_id`. **Workflow** (no `dataset_id`) — checks the operation graph against the operation manifest: unknown operations, missing required parameters, broken `$ref` wiring, structural rules. **Dataset** (`dataset_id` present) — checks a dataset's data against its optional semantic-type schema. Pass `source` to check a file you have uploaded but not yet committed; omit it to report on the dataset as it stands. Reports unresolvable symbols (against the securities reference), unparseable dates, columns that differ from the stored data only by case, duplicate keys, out-of-range numbers and which dates are new — i.e. what will fire a `dataset_trigger`. **Advisory only.** This never blocks a commit and changes nothing; `ok: false` is information. Both shapes answer the same issues array (`severity`, `code`, `message`, `scope`). Column meanings come from the semantic type registry — `GET /schema` returns it as `semantic_manifest`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | no | Validate this dataset. Omit to validate a workflow instead. | | `source` | string | no | s3 uri/key of an uploaded file to check BEFORE committing it. Omit to check the dataset's current data. | | `mutation` | "append" \| "replace" | no | How `source` would be committed. `append` compares against the stored rows, so new dates and duplicates are visible; `replace` judges the file on its own. | | `schema` | object | no | Column meanings to check against; defaults to the dataset's own. Omit on a dataset with no schema to get a `suggested_schema` back. | | `resolve` | boolean | no | Confirm identifiers against the securities reference. `false` checks shape only and is much faster. | | `preview_rows` | number | no | Rows of the incoming data to return with the report. | | `index_parameters` | object | no | Workflow to validate, when no `dataset_id` is given. | **Request** ```bash curl -X POST "https://api.indexone.io/validate" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` | Status | Meaning | | --- | --- | | 400 | Unknown dataset, unreadable source file, or no dataset_id and no workflow. | --- # Execution engine > Execute operations and read the operation catalog. Direct access to the execution engine: run an operation DAG, simulate a workflow, and read the live operation catalog. Unauthenticated at the gateway. ### Execute an operation DAG ```http POST /execute ``` Runs an arbitrary operation DAG on the execution engine and returns each operation's output. This is what the workflow builder calls to preview a node graph — nothing is persisted unless the DAG itself contains a persisting operation. **This route is unauthenticated at the API Gateway** (`AuthorizationType: NONE`, no API key required): it is proxied straight to the workflow service over a VPC link. Treat it accordingly. Pass `websocket_id` to stream per-node results as they complete over the WebSocket API; the HTTP response still returns the full result, truncated to 20 rows per output. Without it, outputs are returned untruncated. **The API Gateway integration timeout is 29 seconds.** A DAG that runs longer will have its HTTP connection cut even though the engine keeps going — which is why long runs must use `websocket_id`. As of 2026-07-21 this endpoint requires a team API key. It was previously reachable without any credential. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `operations` | array of object | no | The DAG to run. | | `websocket_id` | string | no | WebSocket connection id to stream node results to. Also accepted as `connection_id`. | | `team_id` | string | no | | **Request** ```bash curl -X POST "https://api.indexone.io/execute" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "operations": [ { "id": "trigger_1", "operation": "manual_trigger", "parameters": { "time": "2026-07-18 20:00:00" }, "input": [] }, { "id": "eod_1", "operation": "i1_core_eod", "parameters": {}, "input": [ { "$ref": "trigger_1" } ] } ] }' ``` **Response** ```json { "operations": [ { "id": "trigger_1", "operation": "manual_trigger", "state": { "error": false, "suspended": false }, "output": { "trigger_time": "2026-07-18 20:00:00" } }, { "id": "eod_1", "operation": "i1_core_eod", "state": { "error": false, "suspended": false }, "output": [ { "id": "BBG000B9XRY4", "time": "2026-07-18", "close": 233.18 } ] } ] } ``` | Status | Meaning | | --- | --- | | 400 | No `operations` array in the request body. | | 403 | Missing or invalid API key. | | 422 | The request body failed validation before the DAG was built. | | 500 | The DAG raised. `detail` is the exception message, or `{error, traceback}` when the service runs with tracebacks enabled. | ### Get the operation catalog ```http GET /schema ``` Returns the live operation catalog: every operation the execution engine can run, with its parameter schema, input and output contracts, and documentation. There are currently **68 operations**, covering triggers, core data (EOD prices, fundamentals, FX, corporate actions, quotes), dataset loading, transformation, statistics, optimisation, index construction, storage and delivery. This is the authoritative source when building a DAG — operation names, parameter names and enum values must come from here, not from memory, because the catalog changes as operations are added. The response is `{"operation_manifest": {: {...}}}`. It is a few hundred KB. **This route is unauthenticated** and takes no parameters. **Authentication** — none, this endpoint is public. **Request** ```bash curl -X GET "https://api.indexone.io/schema" ``` **Response** ```json { "operation_manifest": { "trigger": { "description": "Fire the flow on a cron schedule, optionally aligned to an exchange calendar session.", "category": "trigger", "parameters": { "cron": { "type": "string", "description": "5-field cron expression.", "required": true }, "timezone": { "type": "string", "default": "UTC" }, "exchange_calendar": { "type": "string", "description": "xcals calendar code." }, "alignment_enabled": { "type": "boolean", "default": false }, "align_time": { "type": "string", "enum": [ "session_open", "session_time", "session_close" ] } } }, "i1_core_eod": { "description": "Core end-of-day prices, market caps and volumes for the securities master.", "category": "core_data", "parameters": { "as_of": { "type": "string", "description": "Point-in-time selection date." }, "columns": { "type": "array", "items": { "type": "string" } } } } } } ``` | Status | Meaning | | --- | --- | | 500 | The engine failed to build the catalog. | ### Backtest a workflow definition ```http POST /simulate ``` Launches a historical simulation of an index definition and returns a pollable backtest id immediately — the same launch-now/fetch-later shape as the MCP `run_backtest` tool. The run replays the DAG over every scheduled date from `start_time` to now; its value series, holdings and statistics are persisted to a backtest blob. Nothing live is created and no schedule is registered. The body is `{"index_parameters": {...}}` — the same object `POST /workflows` would take. Fetch the result from `GET /backtests/{backtest_id}`, which waits server-side for the in-flight window so polling straight after this call is fine. Optionally pass `websocket_id` (or `connection_id`) to *also* stream progress frames over the WebSocket API as the run computes. Because the id comes back at once, the 29-second API Gateway integration timeout can never sever the launch. This route runs a backtest only — `stage` is forced to `backtest`; creating a live index is `POST /workflows`. Requires a team API key. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | object | yes | The workflow definition: `name`, `start_time`, `exchange_calendar`, `operations`. | | `websocket_id` | string | no | Stream results to this WebSocket connection and return immediately. Also accepted as `connection_id`. | **Request** ```bash curl -X POST "https://api.indexone.io/simulate" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "index_parameters": { "name": "Example Momentum Index", "start_time": "2020-01-02 00:00:00", "exchange_calendar": "XNYS", "operations": [ { "id": "trigger_1", "operation": "trigger", "parameters": { "cron": "0 16 * * MON-FRI", "exchange_calendar": "XNYS" }, "input": [] }, { "id": "eod_1", "operation": "i1_core_eod", "parameters": {}, "input": [ { "$ref": "trigger_1" } ] }, { "id": "holdings_1", "operation": "create_index_holdings", "parameters": {}, "input": [ { "$ref": "eod_1" } ] } ] }, "websocket_id": "Yq3TneQMDoECJfw=" }' ``` **Response** ```json { "ok": true, "backtest_id": "bkt_9Fq2LmVt41Xe", "status": "running", "hint": "Poll GET /backtests/bkt_9Fq2LmVt41Xe until status is 'completed' or 'failed'." } ``` | Status | Meaning | | --- | --- | | 400 | No `index_parameters` object in the request body. | | 403 | Missing or invalid API key. | | 422 | The request body failed validation before the backtest was built. | | 500 | The backtest raised. | --- # Overview > What the MCP server exposes and how it is scoped. Index One runs a remote MCP server at `https://api.indexone.io/mcp`. It exposes the platform as 22 tools an AI agent can call: read index data, inspect your datasets, preview a workflow against real data, backtest a strategy, and deploy a live index. MCP (Model Context Protocol) is the open standard for connecting AI applications to external tools. Any MCP-capable client — the Claude connector, Claude Code, Cursor, VS Code, or an agent you built on the MCP SDKs — can connect with a few lines of configuration. ## A tool surface, not a chatbot This is worth being precise about, because it determines who is responsible for what. The MCP server hands out tools. Your client's own model does all of the reasoning: when you ask Claude — or Cursor, or your own agent — to build an index, *that* model plans the work, calls tools one at a time, reads the results and decides what to do next. Nothing is handed off to a model running on Index One's side, and there is no conversation state on our servers. If you switch clients, the quality of the result changes with the model, not with us. What the server does contribute is guidance. On `initialize` it sends a set of instructions that MCP clients pass to their model, describing the correct order of operations and the structural rules a workflow must satisfy. A capable agent therefore knows the path before it starts rather than discovering it by failing. That path is written out on [The golden path](/docs/mcp/golden-path). ## What it exposes | Group | Tools | What it is for | | --- | --- | --- | | Discovery | 7 | Operations, example workflows, workflows and datasets you can build with. | | Data inspection | 3 | Real schemas, samples, distinct values and distributions before you filter on them. | | Index data | 6 | Values, holdings, weightings, universes and risk/return statistics. | | Build and validate | 2 | Preview a workflow against real data; validate a candidate workflow. | | Backtest and deploy | 4 | Simulate history, save a draft, and register a live index. | Every tool, with its exact arguments and annotations, is on the [Tool reference](/docs/mcp/tools) page. Those entries are mirrored verbatim from what the server returns on `tools/list`, so what you read here is what your agent sees. ## Scoping and the write surface Every call is authenticated and scoped to the caller's team. An agent sees your team's workflows, datasets and indices, plus public and featured indices, and nothing else. Both authentication methods — an API key or an OAuth sign-in — resolve to the same team scope, so the connection method never widens what is visible. Writes are deliberately narrow. Of the 22 tools, only four are not read-only, and only two persist anything: `save_workflow` writes a draft (or updates a workflow your team already owns) and `deploy_index` registers a live index. Everything else either reads or runs in preview. > **There is no delete tool** > > Nothing on the MCP surface removes a workflow, dataset or index. This is a deliberate omission rather than > an oversight: an agent acting on an ambiguous instruction should not be able to destroy anything from a chat > window. Deletion stays in the console and the REST API, where a human is doing the clicking. Two further guards sit in front of going live. `run_backtest` never creates an index, however long it runs — it only simulates. And `deploy_index` requires `confirm=true`, runs a full backtest first, and refuses to register an index that ends up with no holdings. ## Why an agent suits index construction Building an index is mostly lookup and iteration rather than insight. You need the exact name of an operation, its exact parameter schema, the real column names in a dataset, and the real values in a column before you can write a filter that does anything. Getting one of those wrong produces an index that runs cleanly and means nothing. That is precisely the work a tool-using model is good at, provided it is made to look things up instead of recalling them. The tool set is shaped around that: `list_operations` and `get_operations` for schemas, `get_example` for real wiring to copy, `inspect_dataset` and `get_column_values` for real columns and real filter values, and `run_workflow` to execute a candidate against live data while persisting nothing. The server instructions say it flatly — never invent operation names, dataset ids, column names or filter values. The honest limitation is the other half: the model still chooses the strategy. Nothing in the tool set checks whether a rule is economically sensible, only whether it is structurally valid and whether it ran. Read the backtest before you deploy. ## Where to go next - [Connect a client](/docs/mcp/connect) — Claude, Claude Code, Cursor, VS Code and custom agents. - [Tool reference](/docs/mcp/tools) — All 22 tools, their arguments and their side effects. - [The golden path](/docs/mcp/golden-path) — The order of operations an agent should follow, and why. - [Protocol notes](/docs/mcp/protocol) — Transport, auth, rate limits, timeouts and error shapes. - [Build an index with an agent](/docs/guides/agent-workflow) — The same path walked end to end as a guide. - [Docs for agents](/docs/agents/overview) — llms.txt, OpenAPI and markdown entry points. --- # Connect a client > Claude, Claude Code, Cursor, VS Code and custom agents. The server URL is `https://api.indexone.io/mcp`. It speaks MCP streamable HTTP and accepts `POST` only. Every client below connects to that one endpoint; what differs is how the credential is attached. > **Any MCP revision connects** > > The server speaks both protocol eras — the current `2026-07-28` revision and the older `initialize` > handshake — and picks per request. Nothing on this page depends on which one your client implements. See > [Protocol notes](/docs/mcp/protocol). ## Pick an authentication method first There are two, and which one you can use is decided by your client, not by preference. **OAuth 2.1** (authorization code with PKCE) works with clients that implement the MCP authorization spec — in practice, the Claude web and Claude Desktop **connectors**. You paste the URL, sign in with your normal Index One console account, and the client holds a token. No key touches a config file. **API keys** cover everything else. Put your team key in the `x-api-key` header (or `Authorization: Bearer `). Create and revoke keys in the console under [Team → API keys](/teamkeys). > **Claude Code cannot use OAuth here** > > Claude Code registers an OAuth callback on a random local loopback port, and Cognito — the identity > provider behind Index One accounts — requires redirect URIs to match an allowlist exactly. A port that changes > every run cannot be allowlisted. The only allowlisted callback is > `https://claude.ai/api/mcp/auth_callback`. Claude Code, Cursor, VS Code and custom agents must therefore use an > API key. This is a limitation of exact-match redirect URIs, not something a setting can turn off. ## Claude — connector (OAuth) The simplest way to use Index One from Claude on the web or in Claude Desktop. Go to **Settings → Connectors → Add custom connector**, paste the server URL, and leave the advanced OAuth fields empty — the server publishes its own discovery documents and a dynamic client registration endpoint, so the client fills them in. Claude opens a sign-in window. Log in with your Index One account — the same email and password as the console — and you are connected. No API key, no config file. ```text https://api.indexone.io/mcp ``` ## Claude Code One command. Claude Code discovers the tools on the next session. ```bash claude mcp add --transport http indexone https://api.indexone.io/mcp \ --header "x-api-key: YOUR_API_KEY" ``` ## Claude Desktop (config file) Claude Desktop's config file connects to remote servers through the `mcp-remote` bridge rather than directly. Add the block below to `claude_desktop_config.json` (**Settings → Developer → Edit Config**) and put your API key in the `env` block. Two details matter. `mcp-remote` substitutes `${...}` values from `env` at startup, which keeps the key out of the `args` list. And write the header with **no spaces around the colon** — `x-api-key:${INDEXONE_API_KEY}`, not `x-api-key: ${INDEXONE_API_KEY}` — because Claude Desktop does not escape spaces inside arguments on Windows and the header is silently split. ```json { "mcpServers": { "indexone": { "command": "npx", "args": [ "-y", "mcp-remote", "https://api.indexone.io/mcp", "--header", "x-api-key:${INDEXONE_API_KEY}" ], "env": { "INDEXONE_API_KEY": "YOUR_API_KEY" } } } } ``` ## Claude Desktop on Windows On Windows, launch the bridge through `cmd /c` instead of calling `npx` directly. Claude Desktop resolves `npx` to its full path under `Program Files`, and the space in that path breaks the launch — you get `'C:\Program' is not recognized` and the server never starts. Everything else is identical. ```json { "mcpServers": { "indexone": { "command": "cmd", "args": [ "/c", "npx", "-y", "mcp-remote", "https://api.indexone.io/mcp", "--header", "x-api-key:${INDEXONE_API_KEY}" ], "env": { "INDEXONE_API_KEY": "YOUR_API_KEY" } } } } ``` ## Cursor Add the server to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` for a single project. ```json { "mcpServers": { "indexone": { "url": "https://api.indexone.io/mcp", "headers": { "x-api-key": "YOUR_API_KEY" } } } } ``` ## VS Code Add the server to `.vscode/mcp.json` in your workspace, or use **MCP: Add Server** from the command palette. Note the key is `servers`, not `mcpServers`. ```json { "servers": { "indexone": { "type": "http", "url": "https://api.indexone.io/mcp", "headers": { "x-api-key": "YOUR_API_KEY" } } } } ``` ## Your own agent (Python SDK) Connect programmatically with the official MCP Python SDK (`pip install "mcp>=2"`). The transport is stateless, so nothing needs to be kept alive between calls beyond the client you open. Headers ride on the `httpx2` client you hand the transport. ```python import httpx2 from mcp import Client from mcp.client.streamable_http import streamable_http_client async with httpx2.AsyncClient(headers={"x-api-key": "YOUR_API_KEY"}) as http: async with Client(streamable_http_client( "https://api.indexone.io/mcp", http_client=http, )) as client: tools = await client.list_tools() result = await client.call_tool( "get_index_values", {"index_id": "idx_...", "limit": 100} ) # Every tool returns one compact JSON text block — parse it. import json payload = json.loads(result.content[0].text) ``` > **SDK v1 still works** > > The server answers both protocol eras, so an existing `ClientSession` + `streamablehttp_client` > integration on `mcp<2` keeps connecting through its `initialize` handshake. Only the result shape changed: > read `content[0].text` and parse it, rather than `structuredContent`. See > [Protocol notes](/docs/mcp/protocol). ## Smoke test Before debugging a client, check the credential from the command line. A raw JSON-RPC `tools/list` should return all 22 tools. ```bash curl -X POST https://api.indexone.io/mcp \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` > **Reading the failure** > > `401` is the credential — and for an OAuth client it is also the normal first response, since the > `WWW-Authenticate` header on it is how the client discovers where to sign in. `403` is a rejected > `Origin`. A `405` back from a GET is normal: the endpoint is POST-only. Streamable HTTP clients are > expected to advertise `application/json, text/event-stream`; this server answers with plain JSON, but sending > only one of the two makes some clients and intermediaries behave inconsistently. - [Tool reference](/docs/mcp/tools) — What you can call once connected. - [The golden path](/docs/mcp/golden-path) — The order an agent should work in. - [Protocol notes](/docs/mcp/protocol) — Auth headers, 401/405/429 shapes and limits. --- # Tool reference > All 22 tools, their arguments and their side effects. The 22 tools the MCP server exposes, grouped by what they are for. Each entry below is generated from the same catalog the server returns on `tools/list`, so the descriptions and argument schemas are exactly what your agent's model sees. The tool registry behind the server holds 24 specs. `submit_workflow` and `final_answer` are terminal tools for the in-app chat agent loop and are not exposed over MCP, which is why they are absent here. > **Reading the annotation chips** > > Every tool carries MCP annotations, and a well-behaved client uses them to decide what to do without > asking. `readOnlyHint` means the tool cannot modify anything, so a client can call it freely — 18 of the 22 are > read-only. `destructiveHint` marks a call that can overwrite existing state and is a signal to prompt the user > first; only `save_workflow` sets it, because passing an existing `workflow_id` replaces that workflow's > operations in place. `idempotentHint` means repeating the call with the same arguments has no additional > effect, which lets a client retry safely on a timeout. `openWorldHint` means the tool reaches beyond stored > state into live external data or a long-running job, so results are not reproducible from one moment to the next. > Annotations are hints, not enforcement: the server's own guards — `confirm=true` on deploy, the absence of any > delete tool — are what actually constrain an agent. ## Discovery Find the operations, examples, workflows and datasets you can build with. This group exists so an agent never has to guess a name. Start here on every build. ### `list_operations` List available workflow operations (id, name, description, category). Call with no args to see all + the category list; pass a category to narrow. Use this to discover real operations instead of guessing. START HERE if you have no other context: the working order is list_operations/list_examples to discover, get_example + get_operations to copy correct wiring, run_workflow to test against real data, then run_backtest to simulate and save_workflow/deploy_index to persist. The write tools validate and preview-run for you — build, don't guess. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `category` | string | no | Optional category filter, e.g. 'index_management'. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_operations", "arguments": { "category": "index_management" } } }' ``` ### `get_operations` Get the full parameter schema and output shape for specific operations. Always fetch an operation's spec before using it so parameters are correct. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `operation_ids` | array | yes | Operation ids to fetch, e.g. ['create_index_weighting']. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_operations", "arguments": { "operation_ids": [ "create_index_weighting" ] } } }' ``` ### `list_examples` List production-tested example workflows (id/name/description). Optional substring search. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `search` | string | no | Optional substring filter. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_examples", "arguments": { "search": "momentum" } } }' ``` ### `get_example` Fetch one or MORE example workflows to copy correct structure and wiring — pass several ids in 'example_ids' in a SINGLE call. Each returned example carries 'index_parameters_json' (a JSON string of the full workflow). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `example_ids` | array | no | One or more example ids to fetch in a single call. | | `example_id` | string | no | A single example id (prefer example_ids). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_example", "arguments": { "example_ids": [ "staggered_effective", "long_short_130_30" ] } } }' ``` ### `list_workflows` List saved workflows. scope='team' (the user's), 'public' (public/featured), or 'all'. When the user names a specific index/workflow, pass search=[] in ONE call — a workflow matching ANY fragment is returned, tagged with which matched, and fragments that hit nothing are listed back. The plain listing is sorted by name and cut at 'limit', so the one you need may not be in it (the response says when it was cut). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `scope` | "team" \\| "public" \\| "all" | no | Default 'team'. | | `search` | array | no | One or more case-insensitive fragments matched on name/description/id; put every candidate name in the same call. | | `limit` | integer | no | Default 50. | | `offset` | integer | no | Skip the first N (pagination). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_workflows", "arguments": { "scope": "public", "limit": 10 } } }' ``` ### `get_workflow` Fetch one workflow by id. Returns id/name/operation_ids plus 'workflow_json' (a JSON string of the full workflow's index_parameters, including its operations). Copy its operations ONLY when the user asks to edit, extend, or duplicate THAT workflow. To build an index ON another index (use its members/weights), read its stored holdings/universe at runtime instead of copying its operations — and never treat a name-similar workflow as a template: its operations implement its own request, not the current one. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_workflow", "arguments": { "workflow_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `list_datasets` List the user's team datasets (id/name/description). Never invent dataset ids — use these. **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_datasets", "arguments": {} } }' ``` ## Data inspection Look at real data before writing a filter against it. These tools answer the two questions that break workflows quietly: what are the columns actually called, and what values are actually in them. They read either a stored dataset or a cached `run_workflow` output. ### `inspect_dataset` Inspect a team dataset's real data. view='schema' (columns+dtypes), 'sample' (rows), 'shape', 'unique' (distinct values of 'column'), or 'stats' (per-column distribution summary). Use before filtering on it. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | | | `view` | "schema" \\| "sample" \\| "shape" \\| "unique" \\| "stats" | no | | | `column` | string | no | Required when view='unique'. | | `limit` | integer | no | | | `sample_rows` | integer | no | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "inspect_dataset", "arguments": { "dataset_id": "dst_9KcQm2", "view": "schema" } } }' ``` ### `inspect_run` Introspect a run_workflow output — send it IN THE SAME TURN as that run_workflow and omit run_id (calls in one turn run in order against one session, so it reads the run just made); a later turn works too but costs an extra round trip. view='schema'|'sample'|'shape'|'unique'|'stats'. For 'unique' pass the 'column' (e.g. which sector values exist before filtering). 'stats' returns per-column count/nulls/mean/std/min/quartiles/max — use it to find which factor/score/weight column is skewed or dominates (pass 'column' for one, omit for all). Defaults to the most recent run and, if it has one operation, that operation. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `op_id` | string | no | | | `view` | "schema" \\| "sample" \\| "shape" \\| "unique" \\| "stats" | no | | | `column` | string | no | | | `limit` | integer | no | | | `sample_rows` | integer | no | | | `run_id` | string | no | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "inspect_run", "arguments": { "view": "stats" } } }' ``` ### `get_column_values` Distinct values of a column — from a dataset (dataset_id) or a run (op_id/run_id). The canonical way to discover real filter values (sectors, countries, ratings, ...). SEND IT IN THE SAME TURN as the run_workflow that produces the data and OMIT run_id: calls in one turn run in order against one session, so it reads the run just made. Waiting a turn to look up a column costs a whole extra round trip. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `column` | string | yes | | | `dataset_id` | string | no | | | `op_id` | string | no | | | `run_id` | string | no | | | `limit` | integer | no | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_column_values", "arguments": { "column": "sector" } } }' ``` ## Index data Read values, holdings, weightings, universes and statistics. All six work on your team's indices and on public or featured ones. Times are strings — `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` — and the snapshot tools resolve at-or-before the time you give, defaulting to the latest. `get_index_values` caps at 5000 points. Longer series are downsampled evenly, always keeping the latest point, and the response reports `{count, returned, downsampled}` so a model can tell it is looking at a thinned series. ### `get_index` Get an index's metadata: name, description, stage/status, calendar settings, value series, and an operation summary. Works for your team's indices and public ones. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `get_index_values` Historical index value series (EOD rows with every value series, e.g. PR/TR). Optional ISO start_time/end_time bounds; long series are evenly downsampled to the point cap, always keeping the latest point. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `start_time` | string | no | Lower bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | Upper bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `limit` | integer | no | Max points returned (cap 5000). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_values", "arguments": { "index_id": "idx_bh7fgXWJMaa3", "start_time": "2024-01-01" } } }' ``` ### `get_index_holdings` Index holdings snapshot (constituents with shares, weights, divisor) at-or-before 'time' (default: latest). Pass 'limit' for the top-N by weight. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `time` | string | no | 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'; defaults to the latest snapshot. | | `limit` | integer | no | Top-N holdings by weight. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_holdings", "arguments": { "index_id": "idx_bh7fgXWJMaa3", "limit": 10 } } }' ``` ### `get_index_weightings` Index weighting snapshot at-or-before 'time' (default: latest). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `time` | string | no | 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'; defaults to the latest snapshot. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_weightings", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `get_index_universe` Index universe snapshot (eligible securities) at-or-before 'time' (default: latest). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `time` | string | no | 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'; defaults to the latest snapshot. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_universe", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `get_index_stats` Risk/return summary from the index's EOD value series: cumulative + annualized return, annualized volatility, max drawdown. 'series' picks a value column (default 'value'). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index id (idx_...) or backtest id (bkt_...). | | `start_time` | string | no | Lower bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | Upper bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `series` | string | no | Value series column, e.g. 'value' or a TR series id. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_stats", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ## Build and validate Preview a workflow and check a workflow before saving. `run_workflow` executes against real source data but persists nothing, so an agent can iterate as often as it needs to; its outputs are cached per team for two hours so `inspect_run` and `get_column_values` can reference an earlier run by `run_id`. ### `run_workflow` Run a single operation, sub-workflow, or full workflow in PREVIEW mode (nothing is persisted; source data is real). Returns per-operation state and an output SUMMARY (columns, shape, small sample). Use it to test that a step works and to discover the real columns an operation produces, then inspect_run / get_column_values on the result. Omit 'operations' entirely to run the CURRENT CANVAS as-is (the cheap way to inspect the existing workflow's real data). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `operations` | string | no | JSON string: array of operation objects [{id, operation, parameters, input?}]. Omit to run the current canvas unchanged. | | `note` | string | no | Optional note about what you're testing. | **Behaviour** — read-only, idempotent, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_workflow", "arguments": { "operations": "[{\"id\":\"t\",\"operation\":\"manual_trigger\",\"parameters\":{}}]", "note": "smoke test" } } }' ``` ### `validate_workflow` Validate a candidate workflow against the manifest (structure + wiring; nothing is executed). Returns structured issues. Optional early check while drafting — submit_workflow, run_backtest, deploy_index and save_workflow all validate automatically and return the same issues. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "validate_workflow", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ## Backtest and deploy The only tools that write. `run_backtest` simulates and never creates an index. `save_workflow` persists a draft, or updates a workflow your team already owns. `deploy_index` is the single tool that puts an index into continuous scheduled calculation, and it requires `confirm=true`. Both `run_backtest` and `deploy_index` validate the workflow synchronously (a separate `validate_workflow` call first is unnecessary) and return immediately with a `backtest_id` — the API gateway in front of the server times out at 29 seconds, so runs are never awaited inline. Poll `get_backtest`, which absorbs part of the wait server-side; `deployed_index_id` appears there once a deploy finishes. ### `run_backtest` Run a historical simulation of a workflow (index_parameters JSON). Validates AND runs the workflow once in preview first (structured issues + runtime errors with hints returned on failure — no separate validate_workflow or run_workflow call needed), then launches the run and immediately returns a backtest_id — poll get_backtest until status is 'completed' or 'failed'. Never creates a live index — use deploy_index. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | | `start_time` | string | no | Backtest start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to index_parameters.start_time). | | `end_time` | string | no | Backtest end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_backtest", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `get_backtest` Fetch a stored backtest result by backtest_id: performance summary and, with include_series=true, the value series evenly downsampled to max_points (default 500) for charting. Waits briefly server-side when the run is still in flight, so polling back-to-back is fine. After deploy_index, also reports the deployed live index id. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `backtest_id` | string | yes | Backtest id (bkt_...). | | `include_series` | boolean | no | Include the value series (downsampled). | | `max_points` | integer | no | Series point cap when include_series=true (default 500, max 2000). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_backtest", "arguments": { "backtest_id": "bkt_7Hs2Qa", "include_series": true } } }' ``` ### `save_workflow` Persist a workflow for the caller's team. Without workflow_id: creates a new DRAFT workflow. With workflow_id: updates an owned workflow in place (a live workflow's changed operations trigger a live reload). Always validated AND preview-verified first (runtime problems bounce with hints; verify=false skips the preview); never changes stage. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | | `workflow_id` | string | no | Existing workflow id to update; omit to create a draft. | | `name` | string | no | Optional display name override. | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, **destructive**. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "save_workflow", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `deploy_index` Create a LIVE index from a saved workflow_id or inline index_parameters: validates AND preview-verifies the workflow (runtime problems bounce with hints; verify=false skips the preview), then runs a full backtest, persists its history, and registers the index for continuous scheduled calculation. Requires confirm=true. Returns the pending index_id + backtest_id immediately — poll get_index(index_id) or get_backtest(backtest_id) until the index lands on 'live' or 'failed' (deployed_index_id appears in get_backtest when done). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | no | Saved workflow id to deploy. | | `index_parameters` | string | no | JSON string of index_parameters (alternative to workflow_id). | | `start_time` | string | no | History start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | History end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `confirm` | boolean | no | Must be true to actually deploy. | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "deploy_index", "arguments": { "workflow_id": "idx_bh7fgXWJMaa3", "confirm": true } } }' ``` ## Next - [The golden path](/docs/mcp/golden-path) — The order to call these in, and why it matters. - [Protocol notes](/docs/mcp/protocol) — How tool errors are returned, and the limits around them. - [Operation catalog](/docs/agents/operations) — The 68 workflow operations the build tools describe. --- # The golden path > The order of operations an agent should follow. The server sends a set of instructions to the client on `initialize`, and MCP clients pass those to their model. The core of them is an ordering: the sequence of tool calls that takes an idea to a live index without wasting calls or producing something that validates but is wrong. The order is not arbitrary. Each step is cheap enough to fail at, and fails in a way that tells the model what to fix, before the next step becomes expensive. This page is that ordering with the reasoning attached. ```text get_example copy wiring from a real workflow (the catalog ships in the instructions) get_operations exact schemas — only for operations you add or re-parameterize inspect_dataset real columns, real values get_column_values run_workflow preview against real data (nothing saved) inspect_run validate_workflow optional draft check (the run tools validate on their own) save_workflow persist as a draft run_backtest validates + simulates, returns a backtest_id immediately get_backtest poll: run summary (+ chart-sized series) deploy_index register a live index (confirm=true) ``` ## The steps 1. **list_operations — find out what exists** Call it with no arguments for everything plus the category list, or pass a category to narrow. The point of starting here is negative: it establishes that the operation you were about to name from memory either exists or does not. An invented operation name is the single most common way an agent-built workflow fails, and it fails late. 2. **get_example — copy wiring, do not invent it** `list_examples` then `get_example` returns production-tested workflows with `index_parameters_json` — the full workflow as a JSON string. Reading one teaches the DAG shape faster than any schema does, because it shows how operations are actually chained rather than what each accepts in isolation. Pass several ids in `example_ids` to fetch them in one call; that is cheaper than separate calls and useful when the index combines patterns from more than one example. 3. **get_operations — get the exact parameter schema** An example shows one valid parameterisation, not the full set. Before using an operation, fetch its spec so every parameter name and type is right. This is the step that catches the plausible-but-wrong parameter — the one that is accepted as an unknown key and silently does nothing. 4. **inspect_dataset and get_column_values — look at the data** `inspect_dataset` with `view='schema'` gives real column names and dtypes; `view='stats'` gives per-column distributions. `get_column_values` gives the distinct values in a column — the canonical way to discover the real sector, country or rating strings a filter has to match. Filtering `sector == "Tech"` when the data says `"Information Technology"` produces an empty universe and no error. 5. **run_workflow — preview against real data** Run a single operation, a sub-workflow or the whole workflow in preview mode. Source data is real; nothing is persisted. The response is a summary — columns, shape, a small sample — and the full outputs are cached for two hours, so `inspect_run` and `get_column_values` can drill into them by `run_id` and `op_id`. **Why preview before validate.** Validation checks structure: that the graph is wired legally and the parameters typecheck. It cannot tell you that a filter matched zero rows, that a score column is entirely null, or that a weighting is dominated by one name. Only running it does. A workflow that validates and produces nothing is the failure mode this step exists to catch. 6. **validate_workflow — check structure before you save** Validates a candidate `index_parameters` against the manifest and returns structured issues rather than a pass/fail. It is cheap and it is specific, which makes it the right last check before anything is written. Saving an invalid workflow leaves a broken draft behind for a human to find later; validating first keeps the failure inside the conversation. 7. **save_workflow — persist a draft** Without `workflow_id`, this creates a new draft. With one, it updates a workflow your team owns in place — and if that workflow is live, its changed operations trigger a live reload. The tool always validates before writing and never changes stage, so saving cannot accidentally promote a draft to live. Save before backtesting so there is something durable to refer back to. A backtest run against inline parameters that only ever existed in a chat message is not reproducible. 8. **run_backtest — simulate history** Runs the workflow over history and returns a performance summary. It never creates a live index, no matter how it is called. **Why backtest before deploy.** Deploying is the point at which an index starts being calculated every session, gets an id that other systems can reference, and starts producing history someone may rely on. The backtest is the last opportunity to see the strategy's behaviour — returns, volatility, drawdown, turnover — at zero cost. Read it. Structural validity says nothing about whether the rule does what was intended. The tool launches the run and immediately returns a `backtest_id`; poll `get_backtest` until the status is `completed` or `failed`. `include_series=true` with `max_points` returns a chart-sized value series. When a poll arrives before the run finishes, `get_backtest` waits briefly server-side before answering, so back-to-back polling is cheap. 9. **deploy_index — go live, with confirmation** Takes a saved `workflow_id` or inline `index_parameters`, runs a full backtest, persists that history, and registers the index for continuous scheduled calculation. **Why confirm is required.** `confirm=true` is an explicit, separate argument so that deploying cannot happen as a side effect of a model reading "make me an index" as an instruction to ship. It forces the agent to state the intent deliberately, and gives the client a natural place to put a human in the loop. A safety gate also refuses to register an index that resolves to no holdings. The tool returns the pending `index_id` and `backtest_id` immediately — poll `get_backtest(backtest_id)`; `deployed_index_id` appears there when it completes. ## Structural rules Three rules are enforced at validation. An agent that knows them up front saves a round trip. - Every flow starts with a trigger operation. The trigger is what defines the schedule the index recalculates on. - The workflow must include `create_index_holdings`. Holdings — actual share counts — are what the value engine calculates from; a workflow that stops at weights produces no index. - Every flow must persist a result. A branch that computes something and drops it is treated as an error, not as dead code to ignore. ## The $ref convention Operations are wired together in two places, and both are required. In an operation's `parameters`, refer to an upstream output with `{"$ref": ".output#column"}`. Separately, list that upstream operation in the operation's `input` array. The redundancy is deliberate: `input` is what builds the DAG and determines execution order, while `$ref` is what binds a specific column into a specific parameter. Supplying the `$ref` alone is the classic mistake — the parameter points at an operation the engine has no reason to run first. ```json { "id": "weighting", "operation": "create_index_weighting", "input": [{ "$ref": "screen" }], "parameters": { "weighting_column": { "$ref": "screen.output#market_cap" } } } ``` ## A worked example What the path looks like in practice, building a market-cap weighted index of the largest US technology companies. The narrative below is the sequence of tool calls, with what each one settles. 1. `list_operations()` — returns every operation with its category. The agent finds the trigger operations, the security-selection operations, `create_index_weighting` and `create_index_holdings`, and confirms it is not inventing names. 2. `list_examples(search="market cap")` then `get_example(example_ids=["staggered_effective"])` — a production workflow with the same shape. Its `index_parameters_json` shows the real wiring: trigger, then selection, then weighting, then holdings, with `input` arrays and `$ref` parameters already correct. 3. `get_operations(operation_ids=["create_index_weighting","create_index_holdings"])` — exact parameter schemas for the two operations the agent will change, rather than assuming the example used every available option. 4. `get_column_values(column="sector", dataset_id="…")` — the real sector strings. This is where `"Tech"` becomes `"Information Technology"`. If the index is driven by a team dataset rather than reference data, `list_datasets` and `inspect_dataset(view="schema")` come first. 5. `run_workflow(operations=…)` with just the trigger and the selection step — a preview against real data, persisting nothing. `inspect_run(view="shape")` confirms the filter matched a sane number of securities rather than zero or everything. 6. `run_workflow` again with the weighting and holdings steps appended, then `inspect_run(view="stats", column="weight")` — checks the weight distribution is not dominated by a single name and that nothing is null. 7. `validate_workflow(index_parameters=…)` — structure, wiring and manifest conformance. Returns issues to fix, not a bare failure. 8. `save_workflow(index_parameters=…)` — persists a draft and returns a `workflow_id`. 9. `run_backtest(index_parameters=…, start_time="2014-12-28")` — returns a `backtest_id` immediately. `get_backtest(backtest_id, include_series=true)` returns the performance summary and the downsampled series once complete. This is the point at which a human should look at the numbers. 10. `deploy_index(workflow_id="…", confirm=true)` — full backtest, history persisted, index registered for scheduled calculation. Poll `get_backtest(backtest_id)` — it reports `deployed_index_id` when it lands. > **Never invent, always discover** > > If there is one rule to keep from this page it is the one the server instructions state directly: never > invent operation names, dataset ids, column names or filter values. Every one of them is discoverable with a > single tool call, and every one of them fails quietly rather than loudly when it is wrong. A guessed column name > produces an index; it just produces the wrong one. - [Tool reference](/docs/mcp/tools) — Exact arguments for each step above. - [Build an index with an agent](/docs/guides/agent-workflow) — The same path as a full guide. - [Operation catalog](/docs/agents/operations) — Every operation the build tools describe. --- # Protocol notes > Transport, sessions, limits and error handling. Everything about the transport, authentication, limits and error shapes that a client author needs and a client user occasionally has to debug. ## Transport | Property | Value | | --- | --- | | Endpoint | `https://api.indexone.io/mcp` | | Transport | MCP streamable HTTP, stateless (`json_response=True`) | | Protocol revisions | `2026-07-28` and `2025-11-25` and earlier — both eras served | | Methods | `POST` only | | Server name | `indexone` | | Server version | `1.4.0` | | Tools | 22 | Responses come back as plain JSON. No SSE stream is required or offered, so a client that insists on upgrading to an event stream will not get one — send `accept: application/json, text/event-stream` and take the JSON. ## Protocol revisions The server answers both eras of the protocol on the same endpoint, and picks per request based on how the client opens. There is nothing to configure either way. A **modern** client (revision `2026-07-28`) sends no handshake: every request carries its protocol version and capabilities in `_meta`, mirrored into the `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers. Call `server/discover` to read the server identity, capabilities and instructions in one request. A **legacy** client (`2025-11-25` and earlier) sends `initialize` and gets the same server info and instructions back in the handshake result. Hosted Claude connectors are still on this path, so it is fully supported rather than merely tolerated. > **Instructions live in two places** > > The server's agent instructions — the operating guide the model reads — come back on `initialize` for > legacy clients and in the `server/discover` result for modern ones. Calling `server/discover` is optional in > the modern revision, so a client that skips it never sees them. Nothing required for correctness depends on > them: every rule that must hold is enforced by the write tools themselves. ## Response caching `tools/list` and `server/discover` results carry `ttlMs` and `cacheScope` freshness hints. Both are identical for every caller — the tool list is rendered from a static registry with no per-team filtering — so both are `"public"` with a one-hour TTL, and the tool order is stable across calls. A client that honours the hints stops re-fetching about 50KB of schema and instructions on every session. Deploys restart the server, which is also when a cached copy legitimately goes stale. ## POST only `GET` and `DELETE` return `405` with an `Allow: POST` header and this body: ```json { "error": "method_not_allowed", "message": "This MCP endpoint is stateless: POST only." } ``` Some MCP clients open a `GET` to establish a server-to-client event stream, and some send `DELETE` to tear down a session. Revision `2026-07-28` removed both — the GET stream along with session ids — and states that a server receiving them from an older client should answer `405`, which is what happens here. An `Mcp-Session-Id` or `Last-Event-ID` header on a request is ignored; streams are not resumable. ## Why the server is stateless There is no MCP session id. Every request is self-contained, carrying its own credentials and everything the handler needs. That was originally an infrastructure constraint. The server sits behind API Gateway over a VPC link, which buffers responses rather than streaming them, and behind a network load balancer with no session stickiness — two consecutive requests from the same client can land on different tasks, so a session id issued by one task would be meaningless to the next. As of revision `2026-07-28` it is also what the specification requires: protocol-level sessions and the `Mcp-Session-Id` header were removed, and servers that need state across calls are told to mint explicit handles and pass them as ordinary tool arguments — which is exactly what the run cache below does. ## The run cache Stateless transport does not mean no continuity. Cross-request continuity comes from a per-team run store rather than from the protocol. When `run_workflow` executes a preview, its outputs are cached against the calling team and given a run id — `run_1`, `run_2`, and so on, numbered per team. `inspect_run` and `get_column_values` can then reference an earlier run by `run_id` and `op_id`, which is what makes "run it, then look at what came out" work across separate tool calls. | Property | Value | | --- | --- | | TTL | 2 hours | | Row cap | 20,000 rows per output | | Run ids | `run_1`, `run_2`, … per team | | Default | The most recent run, and — if it has one operation — that operation | > **The cache is a convenience, not storage** > > Preview outputs expire after two hours and are truncated at 20,000 rows per output. An agent returning to > a conversation the next day will find its runs gone and must re-run the workflow. Nothing in the cache is > persisted state — `run_workflow` writes nothing durable by design. ## Authentication Two methods, both resolving to a team scope. See [Connect a client](/docs/mcp/connect) for the per-client setup. **API key.** Send `x-api-key: ` or `Authorization: Bearer `. The key is resolved against the API Gateway key registry and maps to a team. Resolutions are cached for 300 seconds, with a forced refresh on a miss no more often than every 30 seconds, so a key issued moments ago works without waiting out a cache window. **OAuth 2.1.** Authorization code with PKCE, backed by Cognito, for clients implementing the MCP authorization spec — in practice the Claude web and Claude Desktop connectors. The user signs in with their normal Index One console account and no key touches a config file. | Discovery document | Purpose | | --- | --- | | `/.well-known/oauth-protected-resource` | Protected resource metadata | | `/.well-known/oauth-protected-resource/mcp` | Same, path-scoped to the MCP resource | | `/.well-known/oauth-authorization-server` | Authorization server metadata | | `/.well-known/openid-configuration` | OpenID Connect discovery | | `POST /oauth/register` | Dynamic client registration shim (RFC 7591) | The registration endpoint is a shim, and it is **static**: every registrant is handed the same pre-provisioned public client. It exists because clients following the MCP authorization spec expect to register before they can begin a flow. Cognito enforces the real redirect-URI allowlist, so handing out a shared client id grants nothing on its own. > **Dynamic client registration is deprecated upstream** > > Revision `2026-07-28` deprecates RFC 7591 dynamic client registration in favour of Client ID Metadata > Documents, with a minimum twelve-month window before removal. The shim keeps working and stays available for > authorization servers that do not support CIMD — which includes Cognito today. ## Origin The specification requires servers to validate the `Origin` header to prevent DNS rebinding. A request with no `Origin` is accepted — that is every non-browser client, including Claude Code, `mcp-remote`, curl and the Python SDK. A request that does carry one must present a recognised origin, or it gets `403`. `Host` is deliberately not validated: the endpoint sits behind API Gateway over a VPC link, so the inbound host is an infrastructure detail rather than a security signal. | Parameter | Value | | --- | --- | | Scope | `https://api.indexone.io/mcp/mcp` | | `code_challenge_methods_supported` | `["S256"]` | | `token_endpoint_auth_methods_supported` | `["none"]` | | Grant types | `["authorization_code", "refresh_token"]` | | Allowlisted callback | `https://claude.ai/api/mcp/auth_callback` | > **Only one callback is allowlisted** > > Cognito requires redirect URIs to match exactly. Clients that register on a random local loopback port — > Claude Code among them — cannot complete an OAuth flow against this server, because the port changes every run > and cannot be pre-approved. Claude Code, Cursor, VS Code and custom agents must authenticate with an API key. ## 401 Unauthorized A missing or invalid credential returns `401` with a `WWW-Authenticate` header pointing at the protected-resource metadata, which is how a spec-compliant client discovers it should start an OAuth flow. ```http WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource", scope=".../mcp/mcp" ``` ```json { "error": "unauthorized", "message": "Provide a valid Index One API key in the 'x-api-key' header (or 'Authorization: Bearer '), or connect via OAuth. Keys are issued per team in the Index One console." } ``` ## Rate limits 60 requests per minute **per team**, counted in a fixed 60-second window rather than a rolling one. The limit is shared across everyone and everything using that team's credentials, so several agents on one key contend with each other. It can be overridden per deployment with the `MCP_RATE_LIMIT_PER_MIN` environment variable. The three tools that run a full workflow server-side — `run_backtest`, `save_workflow` and `deploy_index` — share a second, narrower window of 10 per minute per team (`MCP_WRITE_RATE_LIMIT_PER_MIN`) on top of the overall one. Cheap reads and polls never consume it, so an agent polling `get_backtest` cannot starve a deploy, and a runaway loop cannot fire sixty deploys a minute. The narrower window is keyed off the `Mcp-Name` header, which only modern clients send; a legacy client is covered by the overall window alone. Over either limit returns `429` with a `Retry-After` header giving the seconds until the next window opens. Because the window is fixed, that value is often small — waiting it out is usually better than backing off exponentially. ```json { "error": "rate_limited", "message": "Rate limit exceeded for this team. Retry shortly." } ``` ## Timeouts and polling API Gateway enforces a 29-second integration ceiling. Any tool call that could exceed it has to return before it does, or the client sees a gateway error instead of a result. `run_backtest` and `deploy_index` therefore never wait on the run at all: they validate synchronously, launch the job server-side, and immediately return a `backtest_id` (deploys also return the pending `index_id`). Poll `get_backtest(backtest_id)` for the outcome — including `deployed_index_id`, which appears there once a deploy completes. This is the MCP call-now/fetch-later pattern: the id is a durable handle, and polling is the authoritative way to observe the terminal status. `get_index_values` handles the same ceiling differently: rather than truncating, it caps at 5000 points and downsamples evenly, always keeping the latest point, and reports `{count, returned, downsampled}` so a model can tell that it is reading a thinned series. ## Tool result shape Every tool returns its payload as a **single compact JSON text block** — one `content` entry of type `text` whose body is the result object. Parse that string to get the result. ```json { "content": [ { "type": "text", "text": "{\"ok\":true,\"workflow_id\":\"idx_...\"}" } ], "isError": false } ``` > **structuredContent is no longer sent** > > Earlier versions returned the same payload twice — once as `structuredContent` and once as > pretty-printed text — which roughly doubled every response. No tool declares an `outputSchema`, which is what > `structuredContent` exists to be validated against, so only the text block is sent now. A client that read > `structuredContent` should read `content[0].text` and parse it. Payloads are about 59% smaller as a result. ## Tool errors Tool dispatch never raises. An unknown tool name, a malformed JSON argument, a `TypeError` from wrong argument types, or any exception inside a handler comes back as an ordinary tool result — inside that same text block: ```json { "error": "..." } ``` This is a deliberate choice. A JSON-RPC-level error tends to abort the client's tool loop or surface as an opaque failure to the user, whereas an error returned as a result lands back in the model's context where it can be read and acted on. An agent that passes `operation_ids` as a string instead of an array gets told so and retries; it does not lose the conversation. The corollary for client authors: parse `content[0].text` and check the result for an `error` key. A successful protocol response does not mean the tool succeeded. ## Raw JSON-RPC Listing tools. Useful as a credential smoke test. ```bash curl -X POST https://api.indexone.io/mcp \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` Calling a tool. Arguments go in `params.arguments`, matching the tool's `inputSchema`. ```bash curl -X POST https://api.indexone.io/mcp \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_index_values", "arguments": { "index_id": "idx_bh7fgXWJMaa3", "limit": 100 } } }' ``` Both take the legacy path, which is the shortest thing to type by hand. A modern client instead sends `MCP-Protocol-Version: 2026-07-28` and `Mcp-Method` headers plus a matching `params._meta` block — the header and the body must agree, or the request comes back `-32020`. Real integrations get that from their SDK rather than assembling it by hand. ## Server card A static capability card is served, unauthenticated, at `GET /.well-known/mcp/server-card.json`: ```json { "serverInfo": { "name": "indexone", "version": "1.4.0" }, "authentication": { "required": true, "schemes": ["oauth2", "apiKey"] }, "tools": [ ... ], "resources": [], "prompts": [] } ``` It exists because anonymous scanners — registry crawlers, directory listings, client discovery UIs — cannot complete an MCP handshake against a server that requires credentials, and would otherwise have nothing to show. The card gives them the server identity and tool list without granting access to anything. It is static: it does not reflect the caller's team, because there is no caller. `resources` and `prompts` are empty. The server exposes tools only. ## Registry manifest Index One publishes a `server.json` manifest declaring the name `io.indexone/mcp`, the title "Index One", and a single remote entry with `remotes[0].type = "streamable-http"`. Clients that browse the MCP registry can find and add the server from that entry without any manual configuration — they still have to authenticate afterwards. - [Connect a client](/docs/mcp/connect) — Per-client configuration for the above. - [Tool reference](/docs/mcp/tools) — Argument schemas for every tool. - [Rate limits & timeouts](/docs/start/rate-limits) — The same ceilings as they apply to the REST API. --- # Docs for agents > llms.txt, OpenAPI, markdown pages and MCP. These docs are published twice: once as a website for people, and once as plain text for language models. The second version is not a summary or a subset — it is the same content model rendered differently, emitted at build time from the same source. Nothing can drift between them, because neither is written by hand. If you are pointing a coding assistant at Index One, give it one of the files below rather than the HTML. ## The machine-readable surface | URL | What it is | | --- | --- | | `/llms.txt` | An llmstxt.org index of every page with a one-line description. Start here. | | `/llms-full.txt` | The entire documentation suite as one markdown file. | | `/openapi.json` | OpenAPI 3.1 specification of the REST API. | | `/docs/
/.md` | Any single page as markdown, e.g. `/docs/guides/deliveries-setup.md`. | | `https://api.indexone.io/mcp` | The MCP server — tools an agent can call directly. | | `https://api.indexone.io/schema` | The live operation catalog. Public, unauthenticated, 68 operations. | ## llms.txt The `llmstxt.org` convention proposes that a site publishes `/llms.txt` at its root, a short markdown file listing its pages as links with one-line descriptions. It is a table of contents written for a model rather than a crawler — small enough to read whole, structured enough to navigate from. The pattern to use is two-step. Fetch `/llms.txt` to see what exists, then fetch the specific `/docs/
/.md` pages that matter. That costs a fraction of what crawling the site would, and the markdown is unambiguous where rendered HTML is not. ```bash # The index curl https://indexone.io/llms.txt # One page, as markdown curl https://indexone.io/docs/mcp/golden-path.md ``` `/llms-full.txt` concatenates every page into a single file. Use it when a model has a large context window and you would rather load everything once than reason about which page to fetch. Use `/llms.txt` plus selective page fetches when context is scarce or when you only need one topic. > **Markdown costs roughly 90% fewer tokens** > > A rendered documentation page carries navigation, styling, script tags, wrapper elements and duplicated > link text — the overwhelming majority of which is layout rather than content. Fetching `.md` instead of the > HTML for the same page typically costs about a tenth of the tokens, and what remains is the part a model can > actually use. Every page on this site is available at its own path with `.md` appended. ## OpenAPI `/openapi.json` is an OpenAPI 3.1 description of the REST API: every path, parameter, request body, response schema and authentication requirement. It is what the API reference pages on this site are generated from, so it is complete by construction rather than by discipline. Give it to an agent when the task is generating a client, validating a request shape, or reasoning about types. It says what the API accepts; it does not say what you should do with it. That is what the guides are for. ```bash curl https://indexone.io/openapi.json ``` ## Which surface to use The distinction that matters is whether the agent is *helping someone write code* or *doing the work itself*. | Situation | Reach for | | --- | --- | | A coding assistant is helping a human write an integration against Index One | `/llms.txt` and the `.md` pages, plus `/openapi.json` for exact request and response shapes | | You are generating a typed client or SDK | `/openapi.json` alone | | An agent should act on its own — query indices, preview a workflow, backtest, deploy | The [MCP server](/docs/mcp/overview) | | An agent needs to know what workflow operations exist and what they accept | `https://api.indexone.io/schema`, or the [operation catalog](/docs/agents/operations) | The two are complementary rather than alternatives. An agent connected over MCP still benefits from the markdown docs — the tools tell it what it can call, the docs tell it what the results mean. A common setup is MCP for action plus `/llms.txt` for background. ## Copying a single page The **For agents** menu in the header of these docs offers a copy-as-markdown action for the page you are reading, along with links to the files above. If you are about to paste documentation into a chat window, use it — you get the markdown rather than a lossy copy of the rendered page. - [Operation catalog](/docs/agents/operations) — All 68 workflow operations, loaded live from the API. - [MCP overview](/docs/mcp/overview) — What an agent can do directly, and how it is scoped. - [Connect a client](/docs/mcp/connect) — Wire Claude, Cursor or your own agent to the MCP server. --- # Operation catalog > Every workflow operation, loaded live from the API. Operations are the building blocks of a workflow. The catalog below is loaded live from `GET https://api.indexone.io/schema` — public, unauthenticated, and the same response the platform itself reads — so it cannot drift from what the engine will accept. There are 68 operations at the time of writing, and if that number changes this page changes with it. Each entry carries an `id`, a `name`, a `description`, a `category` and a `parameters` schema. The id is what goes in an operation's `operation` field — copy it exactly, never guess it. The parameters schema gives each parameter's name, type and whether it is required; a parameter you invent is accepted as an unknown key and quietly does nothing, which is why reading the schema is worth the round trip. Operations are wired together in two places, and both are required. In an operation's `parameters`, reference an upstream output with `{"$ref": ".output#column"}`. Separately, list that upstream operation in the operation's `input` array — `input` is what builds the DAG and fixes execution order, while the `$ref` binds a specific column into a specific parameter. Supplying only the `$ref` leaves the parameter pointing at an operation the engine has no reason to run first. [The golden path](/docs/mcp/golden-path) covers the convention and the structural rules in full. The full workflow operation catalog is served live and unauthenticated: ```bash curl https://api.indexone.io/schema ``` It returns `{ "operation_manifest": { "": { id, name, description, category, parameters } } }`. --- # Index One REST API reference The Index One REST API covers two generations of the product. **The workflow plane (`idx_...` ids)** is the current system: a workflow is a DAG of operations that is scheduled, executed and persisted by the execution coordinator. Workflow data (values, holdings, weightings, universes, corporate actions) is read through `GET /query`. **The legacy index plane (uuid ids such as `348fb318-b1a4-42f8-9c8b-b618edf2832f-0`, marked `legacy`)** is the original index engine backed by the `{stage}-index` tables. It is still live and still serving production indices, but no new features are being added to it. Operations marked `planned` do **not** exist yet. They are documented here because they are the intended shape of the API and, in several cases, because the console already calls them. ### Base URL `https://api.indexone.io` — there is no stage prefix. Requests to `https://api.indexone.io/prod/...` return `Missing Authentication Token`. ### Authentication Most routes require **both** an API key and a Cognito id token. The API key is validated by API Gateway (and carries the usage-plan rate limit); the Cognito id token identifies the user and is what every handler uses for ownership checks. Sending only one of the two on a both-auth route fails: no key gives `403 Forbidden` from the gateway, no token gives `401 Unauthorized`. ### Error shapes Every error response uses one shape: `{"error": ""}`, with the HTTP status carrying the category. Two optional fields appear where they help: `code` (a machine-readable discriminator, e.g. the Cognito exception class on a sign-in failure) and `issues` (an array of structured problems on validation 422s). See the Errors guide for the status-code list. ## Authentication Cognito-backed sign-up, sign-in, token refresh and password reset. These are the only authenticated-plane routes that need just an API key. ### Start a password reset ```http POST /forgot_password ``` Emails a password-reset confirmation code. Complete the reset with `POST /forgot_password/confirm`. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/forgot_password" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com" }' ``` **Response** ```json { "CodeDeliveryDetails": { "Destination": "q***@e***.com", "DeliveryMedium": "EMAIL", "AttributeName": "email" } } ``` | Status | Meaning | | --- | --- | | 400 | Unknown user, or Cognito rate-limited the reset. The Cognito exception is uncaught, so the body is a generic gateway error. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Complete a password reset ```http POST /forgot_password/confirm ``` Sets a new password using the code emailed by `POST /forgot_password`. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | | | `password` | string | yes | The new password. | | `confirmation_code` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/forgot_password/confirm" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "password": "N3w-Passw0rd!", "confirmation_code": "482913" }' ``` **Response** ```json {} ``` | Status | Meaning | | --- | --- | | 400 | Wrong or expired code, or the new password violates the Cognito policy. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Exchange a refresh token for a new id token ```http POST /refresh_token ``` Returns a fresh id token from the refresh token issued by `POST /signin`. No new refresh token is issued — keep using the original. An expired, revoked or malformed refresh token returns **401** with `{"error": …}` — treat it as a sign-out and re-authenticate. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `refresh_token` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/refresh_token" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.refresh" }' ``` **Response** ```json { "id_token": "eyJraWQiOiJ4WGZQK0k9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiI3ZGNkMjIxZS00Y2Y0LTRjYjUtODQ1ZS0zMmVmYzE4MDNlZTYifQ.sig", "expires_in": 3600, "status": "success" } ``` | Status | Meaning | | --- | --- | | 401 | The refresh token is expired, revoked or invalid — re-authenticate with `POST /signin`. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Sign in and get tokens ```http POST /signin ``` Authenticates against Cognito using the `USER_PASSWORD_AUTH` flow and returns an id token plus a refresh token. Send `id_token` as the `Authorization` header on every authenticated request — **raw, with no `Bearer ` prefix**. It expires after `expires_in` seconds (3600 by default); use `POST /refresh_token` to get a new one without re-prompting for the password. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | Lower-cased server-side, so case does not matter. | | `password` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/signin" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "password": "S3cure-Passw0rd!" }' ``` **Response** ```json { "id_token": "eyJraWQiOiJ4WGZQK0k9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiI3ZGNkMjIxZS00Y2Y0LTRjYjUtODQ1ZS0zMmVmYzE4MDNlZTYiLCJlbWFpbCI6InF1YW50QGV4YW1wbGUuY29tIiwiZXhwIjoxNzg0NTUyMDAwfQ.sig", "refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.refresh", "expires_in": 3600 } ``` | Status | Meaning | | --- | --- | | 401 | Authentication failed. The `code` carries the Cognito exception class: `NotAuthorizedException` for bad credentials, `UserNotConfirmedException` when `POST /signup/confirm` was never called, `UserNotFoundException` for an unknown address. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Create a user account ```http POST /signup ``` Registers a new Cognito user and creates the backing user record. If the email address has a pending team invite the user joins that team; otherwise a new team is created and the user becomes its admin. A confirmation code is emailed — call `POST /signup/confirm` next. Optional profile fields (`firstname`, `lastname`, `company`, `use_case`, `industry`, `position`, `country`, `company_type`) are stored on the user record and forwarded to the CRM. The same route doubles as the marketing contact form: posting `{"hubspot_only": true, ...}` creates only a CRM contact and no user. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | Email address. Lower-cased server-side. | | `password` | string | yes | Must satisfy the Cognito password policy. | | `firstname` | string | no | | | `lastname` | string | no | | | `company` | string | no | | | `hubspot_only` | boolean | no | Create only a CRM contact, no user account. | **Request** ```bash curl -X POST "https://api.indexone.io/signup" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "password": "S3cure-Passw0rd!", "firstname": "Ada", "lastname": "Lovelace", "company": "Example Capital" }' ``` **Response** ```json "7dcd221e-4cf4-4cb5-845e-32efc1803ee6" ``` | Status | Meaning | | --- | --- | | 400 | `User already exists, please login or reset password instead.` when the email is taken, otherwise `Unknown error.` — Cognito policy violations (weak password, invalid email) are collapsed into that generic message. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Confirm a new account ```http POST /signup/confirm ``` Completes registration with the confirmation code emailed by `POST /signup`. After this the user can sign in. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | | | `confirmation_code` | string | yes | | **Request** ```bash curl -X POST "https://api.indexone.io/signup/confirm" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "quant@example.com", "confirmation_code": "482913" }' ``` **Response** ```json "success" ``` | Status | Meaning | | --- | --- | | 400 | Wrong or expired code, or the user is already confirmed. The Cognito exception is not caught, so the gateway surfaces a generic error body. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ## API Keys Create, list and revoke the team's API Gateway keys. Keys carry the usage-plan rate limit. ### List a team's API keys ```http GET /teams/{id}/keys ``` Lists every API Gateway key registered against the team. **Key values are returned in plaintext** (the handler passes `includeValues=True`), so this response is credential material: never log it, cache it or render it outside an authenticated screen. As with key creation, a non-member gets HTTP 200 carrying `{"message": "Not authorized to perform this operation"}` instead of a 403. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Team id. | **Request** ```bash curl -X GET "https://api.indexone.io/teams/33a36e74-b36a-4a50-9a99-edeff5144a43/keys" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "k3n8qz1p42", "value": "8Kd0Wq3mZa7Yr1Xu5Tv9Bc2Ne6Lp4Hs0Gj8Fd3R", "created_at": "2026-03-11", "enabled": true }, { "id": "m7ta4bx915", "value": "2Qp6Rn8Vs1Mj4Ck7Zw0Ye5Ta3Bu9Hd6Lx2Pg4W", "created_at": "2026-06-30", "enabled": false } ] ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Create an API key ```http POST /teams/{id}/keys ``` Creates an API Gateway key for the team and attaches it to the usage plan that carries the rate limit. The plaintext key is in the `value` field of the response — but it is also retrievable later via `GET /teams/{id}/keys`, so it is not a show-once secret. The caller must be a member of the team. **A non-member gets HTTP 200** with `{"message": "Not authorized to perform this operation"}`, not a 403. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Team id. | **Request** ```bash curl -X POST "https://api.indexone.io/teams/33a36e74-b36a-4a50-9a99-edeff5144a43/keys" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "k3n8qz1p42", "value": "8Kd0Wq3mZa7Yr1Xu5Tv9Bc2Ne6Lp4Hs0Gj8Fd3R", "created_at": "2026-07-21", "enabled": true } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Revoke an API key ```http DELETE /teams/{id}/keys/{key_id} ``` Deletes the key at API Gateway, immediately invalidating it everywhere. Deleting a key that does not exist is not an error — the handler swallows the exception and returns `{}`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Team id. | | `key_id` | string | yes | The key's `id` (not its `value`). | **Request** ```bash curl -X DELETE "https://api.indexone.io/teams/33a36e74-b36a-4a50-9a99-edeff5144a43/keys/k3n8qz1p42" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "k3n8qz1p42" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ## Workflows The current index plane: workflows (`idx_...`) are operation DAGs executed by the coordinator. Creation over REST is still a gap — see the `planned` operations. ### List a team's workflows ```http GET /workflows ``` Lists the workflows a team owns. Added 2026-07-21, replacing `GET /workflows/{id}`, where `{id}` confusingly meant a *team* id while the same path template under PATCH, DELETE and /executions meant a *workflow* id. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `team_id` | string | yes | The owning team. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows?team_id=tem_4Kd9Xa" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "idx_bh7fgXWJMaa3", "name": "US Momentum 50", "stage": "live", "created_at": "2026-01-14 09:02:11" } ] ``` | Status | Meaning | | --- | --- | | 403 | Missing or invalid credentials. | | 404 | No such team. | ### Create a live workflow ```http POST /workflows ``` Creates a live index: validates the workflow, persists it as `stage: "pending"`, and runs the backtest and live registration in the background. Returns immediately so the call is never held against the gateway's 29-second ceiling. Poll `GET /workflows/{workflow_id}` and watch `stage`: `pending` becomes `live` once history is written and the index is registered for continuous calculation, or `failed` with a `failure_reason` if validation, execution or the empty-index check rejected it. Failed records are kept, not deleted, so the reason is readable. Pass `websocket_id` to stream progress instead of polling, the same way the console does. This replaces the current go-live path, which is `POST /simulate` with `stage: "live"` buried in `index_parameters` — a call whose name says simulation and whose effect is a production index. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | | | `start_time` | string | yes | Datetime the index history begins at, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`. | | `exchange_calendar` | string | no | `xcals` calendar code driving trading days. | | `team_id` | string | no | Owning team. Would default to the caller's primary team. | | `operations` | array of object | yes | The DAG. Every flow must start with a trigger operation, must include `create_index_holdings`, and must persist its result. Operations reference upstream outputs with `{"$ref": ".output#column"}` and must also list that operation in `input`. | **Request** ```bash curl -X POST "https://api.indexone.io/workflows" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Example Momentum Index", "start_time": "2020-01-02 00:00:00", "exchange_calendar": "XNYS", "operations": [ { "id": "trigger_1", "operation": "trigger", "parameters": { "cron": "0 16 * * MON-FRI", "exchange_calendar": "XNYS" }, "input": [] }, { "id": "eod_1", "operation": "i1_core_eod", "parameters": {}, "input": [ { "$ref": "trigger_1" } ] }, { "id": "holdings_1", "operation": "create_index_holdings", "parameters": {}, "input": [ { "$ref": "eod_1" } ] } ] }' ``` | Status | Meaning | | --- | --- | | 403 | Missing or invalid credentials. | | 422 | The workflow failed validation. The body lists the issues. | ### Get a workflow ```http GET /workflows/{workflow_id} ``` Returns one workflow's whole record — metadata, calendar settings, stage, and its `operations` DAG. This is what you poll after `POST /workflows`: watch `stage` move from `pending` to `live`, or to `failed` with a `failure_reason`. Added 2026-07-21. Before that this path took a *team* id and returned that team's whole list, while the same path under PATCH and DELETE took a workflow id — so there was no way to fetch a single workflow at all. The list moved to `GET /workflows?team_id=`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | **Team** id — not a workflow id. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/33a36e74-b36a-4a50-9a99-edeff5144a43" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "name": "US Momentum 50", "stage": "live", "status": "live", "team_id": "tem_4Kd9Xa", "exchange_calendar": "XNYS", "timezone": "US/Eastern", "created_at": "2026-01-14 09:02:11", "updated_at": "2026-07-20 16:00:04", "operations": [] } ``` | Status | Meaning | | --- | --- | | 403 | The workflow is not in one of your teams — or does not exist. Ownership is checked before existence, so an unknown id is indistinguishable from someone else's, which keeps ids non-enumerable. | | 404 | The workflow is referenced by your team but its row is missing — a data inconsistency, not a normal outcome. A plain unknown id returns 403. | ### Update a workflow ```http PATCH /workflows/{workflow_id} ``` Partially updates a workflow row. The fields `id`, `team_id`, `stage` and `created_at` are blacklisted and silently dropped from the patch — in particular you cannot promote a draft to live this way — going live is what the planned `POST /workflows` does. When the patch touches `operations`, `updated_at` is refreshed and a `workflow_updated` trigger is posted to the coordinator so the running schedule reloads; the outcome of that notification is reported back in `trigger_notification`. The caller must belong to a team that owns the workflow, or be a site admin. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `visibility` | "private" \| "public" | no | | | `operations` | array of object | no | The full operation DAG. Replaces the existing one wholesale. | | `exchange_calendar` | string | no | | | `timezone` | string | no | | **Request** ```bash curl -X PATCH "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Example Momentum Index v2", "visibility": "public" }' ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "name": "Example Momentum Index v2", "visibility": "public", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "stage": "live", "updated_at": "2026-07-21 08:15:02" } ``` | Status | Meaning | | --- | --- | | 400 | `id required`, or `no editable fields` when the body was empty or contained only blacklisted keys. | | 401 | Missing or invalid id token. | | 403 | The caller's teams do not own this workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Delete a workflow and all its data ```http DELETE /workflows/{workflow_id} ``` Permanently deletes the workflow **and every row it produced**. This is not reversible and there is no soft-delete window. The sequence is: notify the coordinator (`workflow_deleted`) so it stops scheduling; mark the row `stage=deleting`; purge every partition of `index-holdings`, `index-values-eod`, `index-weightings`, `index-universes` and `index-corporate-actions` in parallel; delete the workflow row; unlink it from the caller's teams. The response reports how many rows were purged per table; `-1` means that table's purge raised and its data may be partially left behind. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Request** ```bash curl -X DELETE "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "deleted": "idx_bh7fgXWJMaa3", "purged": { "prod-index-holdings": 1482, "prod-index-values-eod": 1663, "prod-index-weightings": 44, "prod-index-universes": 44, "prod-index-corporate-actions": 219 }, "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_5Xn2QpLw81Zk" } } } ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | The caller's teams do not own this workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's holdings ```http GET /workflows/{workflow_id}/holdings ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today: `GET /query?table=index-holdings&pk=idx_...` (or the MCP `get_index_holdings` tool). The intent is a holdings snapshot with resolved identifiers — shares, weight, price and value per constituent for a given date — rather than the raw stored row. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `time` | string | no | Snapshot timestamp. Defaults to the latest. | | `map_symbols` | boolean | no | Resolve FIGIs to ticker symbols. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/holdings" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "time": "2026-07-18 20:00:00", "holdings": [ { "id": "BBG000B9XRY4", "symbol": "AAPL", "shares": 1204.55, "weight": 0.0731, "price": 233.18, "value": 280837.5 } ] } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow, or no holdings at that time. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's performance statistics ```http GET /workflows/{workflow_id}/stats ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today the only workflow-plane equivalent is the MCP `get_index_stats` tool. The intent is the same statistics surface (returns, volatility, Sharpe, Sortino, max drawdown, period returns) computed over an `idx_...` index's value series. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `returns` | string | no | Comma-separated windows to compute returns over. | | `volatility` | string | no | Comma-separated windows for annualized volatility. | | `sharpe` | string | no | Comma-separated windows for the Sharpe ratio. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/stats" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "returns": { "itd": 0.1843, "1y": 0.0912, "ytd": 0.0471, "30d": 0.0128 }, "volatility": { "1y": 0.1633, "ytd": 0.1502 }, "sharpe": { "itd": 0.94, "1y": 0.56 } } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's universe ```http GET /workflows/{workflow_id}/universe ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today: `GET /query?table=index-universes&pk=idx_...` (or the MCP `get_index_universe` tool). The universe is the selection-stage output: every security that passed the filters at a given reconstitution, before weighting. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `time` | string | no | Reconstitution timestamp. Defaults to the latest. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/universe" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "time": "2026-06-30 20:00:00", "universe": [ "BBG000B9XRY4", "BBG000BPH459", "BBG000BVPV84" ] } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's index values ```http GET /workflows/{workflow_id}/values ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today the equivalent read is `GET /query?table=index-values-eod&pk=idx_...` (or the MCP `get_index_values` tool). The intent is a first-class, chart-shaped read for the workflow plane: duration windows, increment down-sampling and a `[{time, value}]` payload, instead of making callers page raw DynamoDB rows through the generic query endpoint. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `duration` | string | no | Window, e.g. `1d`, `30d`, `1y`, `ytd`, `itd`. | | `increment` | string | no | Down-sampling interval, e.g. `1d`, `1w`, `1mo`, `eod`. | | `limit` | integer | no | Return only the most recent N points. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/values" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "time": "2026-07-17 20:00:00", "value": 1178.42 }, { "time": "2026-07-18 20:00:00", "value": 1184.37 } ] ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get a workflow's target weightings ```http GET /workflows/{workflow_id}/weightings ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today: `GET /query?table=index-weightings&pk=idx_...` (or the MCP `get_index_weightings` tool). Weightings are the targets set at each rebalance, as distinct from holdings, which drift with prices between rebalances. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `time` | string | no | Rebalance timestamp. Defaults to the latest. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/weightings" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "idx_bh7fgXWJMaa3", "time": "2026-06-30 20:00:00", "weighting": { "BBG000B9XRY4": 0.0731, "BBG000BPH459": 0.0654 } } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No such workflow. | | 429 | API key usage-plan rate limit exceeded. | ## Data Query `GET /query` is the generic read path for all workflow-plane data: values, holdings, weightings, universes and corporate actions. ### Query workflow-plane data ```http GET /query ``` The main data-read path for `idx_...` indices. It exposes the underlying DynamoDB tables directly as a keyed query: you pick a table, give a partition key (`pk` — the workflow or delivery id) and get back rows in sort-key order. **Tables** (`table`): `index-parameters` (the workflow definition itself, no sort key), `index-holdings`, `index-values-eod`, `index-weightings`, `index-universes`, `index-corporate-actions` (all sorted by `time`), and `deliveries` (no sort key). **Narrowing the read.** `sk` pins one exact sort-key value — the fastest way to fetch a single snapshot. `attributes` is a projection: only the named top-level fields come back, which matters because holdings and universe rows are large. `order=descending` with `limit` is the idiom for "most recent N". **Paging.** `cursor` is an opaque base64-encoded DynamoDB `LastEvaluatedKey`; never parse or construct it. It is returned only when `limit` was reached and more rows exist. Without `limit` the handler pages internally and returns the whole partition in one response — safe for values, expensive for holdings. **Symbols.** `map_symbols=true` adds a `figi_symbols` map covering every identifier that appears anywhere in the payload, so you can render tickers without a second lookup. **Access control.** `pk` must be a workflow or delivery id belonging to one of the caller's teams (site admins bypass this). This is the only authorization check — there is no per-table permission. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `table` | "index-parameters" \| "index-holdings" \| "index-values-eod" \| "index-weightings" \| "index-universes" \| "index-corporate-actions" \| "deliveries" | yes | Which table to read. | | `pk` | string | yes | Partition key: the workflow (`idx_...`) or delivery (`dlv_...`) id. | | `sk` | string | no | Exact sort-key value (`time`). Ignored for tables without a sort key. | | `attributes` | string | no | Comma-separated top-level fields to project. Omit for whole rows. | | `order` | "ascending" \| "descending" | no | Sort-key direction. | | `limit` | integer | no | Maximum rows to return. Also enables cursor paging. | | `cursor` | string | no | Opaque continuation token from a previous response. | | `map_symbols` | boolean | no | Add a `figi_symbols` FIGI-to-ticker map. | **Request** ```bash curl -X GET "https://api.indexone.io/query?table=index-values-eod&pk=idx_bh7fgXWJMaa3" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "items": [ { "id": "idx_bh7fgXWJMaa3", "time": "2026-07-18 20:00:00", "value": 1184.37 }, { "id": "idx_bh7fgXWJMaa3", "time": "2026-07-17 20:00:00", "value": 1178.42 } ], "count": 2, "cursor": "eyJpZCI6IHsiUyI6ICJpZHhfYmg3Zmd..." } ``` | Status | Meaning | | --- | --- | | 400 | `table and pk required`, or `unknown table ` when `table` is not one of the seven allowed aliases. | | 401 | Missing or invalid id token. | | 403 | `pk` is not a workflow or delivery owned by one of the caller's teams. | | 429 | API key usage-plan rate limit exceeded. | ## Executions Execution history for workflows and deliveries. One execution row per operation group per fire. ### List a delivery's executions ```http GET /deliveries/{id}/executions ``` Returns the delivery's entire execution history, from the same `{stage}-executions` table as workflow executions and with the same row shape — deliveries and workflows are indistinguishable to the coordinator. **Here `{id}` is the DELIVERY id.** This is how you confirm a send actually happened: a `completed` state means the send operation returned without error. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Delivery** id. | **Request** ```bash curl -X GET "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92/executions" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "index_id": "dlv_c8KtQ1rPxZ92", "execution_id": "exn_dQ4vTnRy73Lc", "state": "completed", "updated_at": "2026-07-18 20:06:41", "request_context": { "registered_time": "2026-07-18 20:05:02", "execution_time": "2026-07-18 20:06:30", "execution_mode": "live" } } ] ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 429 | API key usage-plan rate limit exceeded. | ### List a workflow's executions ```http GET /workflows/{workflow_id}/executions ``` Returns the workflow's **entire** execution history from the `{stage}-executions` table — there is no limit or cursor parameter, so for a long-running live index this response can be large. Each row is one execution group: its `state`, the `request_context` timings (`registered_time`, `execution_time`, `execution_mode`), `next_execution_time`, and `last_error` when it failed. Note that per-group durations are not stored — they are inferred by comparing a group's `execution_time` to its successor's `registered_time`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id (here it really is the workflow, not the team). | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/executions" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "index_id": "idx_bh7fgXWJMaa3", "execution_id": "exn_bhE9JoGPHwAi", "execution_group_id": "grp_2", "state": "completed", "updated_at": "2026-07-18 20:04:12", "next_execution_time": "2026-07-21 20:00:00", "request_context": { "registered_time": "2026-07-18 20:00:03", "execution_time": "2026-07-18 20:00:15", "execution_mode": "live" } } ] ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 429 | API key usage-plan rate limit exceeded. | ### Get one execution ```http GET /workflows/{workflow_id}/executions/{execution_id} ``` Returns a single execution row in full, including the resolved `operations` DAG as it was executed and each operation's output state. This is the endpoint to use when debugging a failed run — `last_error` on the list endpoint tells you *that* it broke, this one tells you *where*. The execution is returned wrapped: `{"execution": {...}}`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | Workflow id. | | `execution_id` | string | yes | Execution id from the list endpoint. | **Request** ```bash curl -X GET "https://api.indexone.io/workflows/idx_bh7fgXWJMaa3/executions/exn_bhE9JoGPHwAi" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "execution": { "index_id": "idx_bh7fgXWJMaa3", "execution_id": "exn_bhE9JoGPHwAi", "execution_group_id": "grp_2", "state": "completed", "updated_at": "2026-07-18 20:04:12", "request_context": { "registered_time": "2026-07-18 20:00:03", "execution_time": "2026-07-18 20:00:15", "execution_mode": "live" }, "operations": [ { "id": "trigger_1", "operation": "trigger", "state": { "error": false, "suspended": false } }, { "id": "eod_1", "operation": "i1_core_eod", "state": { "error": false, "suspended": false } } ] } } ``` | Status | Meaning | | --- | --- | | 400 | `workflow id and execution_id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this workflow. | | 404 | No execution with that id under this workflow. | | 429 | API key usage-plan rate limit exceeded. | ## Deliveries Scheduled distribution of index data over email, webhook, SFTP, S3 or a partner integration. A delivery is an ordinary workflow — a plain `operations` list — stored in `{stage}-deliveries` and run by the same coordinator as workflows. ### Create a delivery ```http POST /deliveries ``` Creates a delivery rule: a schedule plus a payload plus a destination. Deliveries are workflow-engine rows in their own table and are picked up by the same execution coordinator as workflows, so a delivery behaves exactly like a workflow at runtime. A delivery **is** an ordinary workflow: `operations` is a plain list of operation nodes, exactly as a workflow stores them, and nothing is expanded at run time. Two shapes cover everything: - **Standard** — `trigger -> index_panel -> send_`, where the channel is `send_email`, `send_webhook`, `send_sftp` or `send_s3`. The trigger is `index_event_trigger` (fires when a target index publishes `value_eod`, `universe`, `weighting` or `holdings`), `trigger` (cron) or `manual_trigger`. The console's one-click form writes and reads back exactly these nodes, so the workflow builder shows what actually runs. - **Partner** — `trigger -> send_` (`send_alphabot`, `send_alphathena`, `send_stratifi`, `send_refinitiv`). A partner operation is the whole flow — it reads the data, formats it the one way that partner accepts and sends it over the one transport that partner speaks — so there is no `index_panel` node; you supply only the per-index account binding, such as `ric_map` for LSEG. `operations` is required. Every operation and its parameters are listed in the live catalog at `GET /schema`. The new delivery is linked into the team's `deliveries` map and the coordinator is notified immediately, so a `live` delivery with an `index_event` trigger can fire the same day. Note the success status is **200, not 201**. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `team_id` | string | yes | Owning team. Must be a team the caller belongs to. | | `name` | string | yes | | | `description` | string | no | | | `stage` | "live" \| "draft" | no | `live` starts scheduling immediately. | | `timezone` | string | no | | | `index_id` | string | no | Single target index. Use `index_ids` for several. | | `index_ids` | array of string | no | Target indices. Both `idx_...` and legacy uuid ids are accepted. | | `operations` | array of object | yes | The delivery's operations, in execution order: `trigger -> index_panel -> send_` for a standard delivery, `trigger -> send_` for a partner one. Required. | **Request** ```bash curl -X POST "https://api.indexone.io/deliveries" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "name": "Daily values to ops", "stage": "live", "timezone": "America/New_York", "index_ids": [ "idx_bh7fgXWJMaa3" ], "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "payload_type": "values", "history": "latest", "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "filename_template": "{index_id}_{date}_values.csv" } }, { "id": "delivery_send", "operation": "send_email", "use_cache": false, "input": [ { "$ref": "delivery_payload" } ], "parameters": { "recipients": [ "ops@example.com" ], "subject": "Daily index values", "body": "Attached are today's closing values." } } ] }' ``` **Response** ```json { "id": "dlv_c8KtQ1rPxZ92", "type": "delivery", "name": "Daily values to ops", "stage": "live", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "user_id": "7dcd221e-4cf4-4cb5-845e-32efc1803ee6", "timezone": "America/New_York", "index_ids": [ "idx_bh7fgXWJMaa3" ], "created_at": "2026-07-21 08:35:02", "updated_at": "2026-07-21 08:35:02", "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_5Xn2QpLw81Zk" } } } ``` | Status | Meaning | | --- | --- | | 400 | `name required`, `operations required`, or `no team resolved for this caller`. | | 401 | Missing or invalid id token. | | 403 | Caller is not a member of `team_id`. | | 429 | API key usage-plan rate limit exceeded. | ### Retrieve files a delivery produced ```http GET /deliveries/{delivery_id}/artifacts ``` > **PLANNED** — NOT YET IMPLEMENTED. This endpoint is documented so its intended shape is public; calling it today returns 403 or 404. **PLANNED — this route does not exist.** Today a delivery's output only leaves the system through its configured channel: email attachment, webhook body, or SFTP upload. There is no way to fetch what was sent afterwards, which makes a failed send unrecoverable without re-running the delivery, and makes pull-based integrations impossible. The intent is to list the artifacts each execution generated — filename, target index, size, generation time — with a short-lived presigned URL to download each one. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `delivery_id` | string | yes | Delivery id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `execution_id` | string | no | Restrict to one execution. Defaults to the most recent. | **Request** ```bash curl -X GET "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92/artifacts" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "artifacts": [ { "filename": "idx_bh7fgXWJMaa3_2026-07-18_values.csv", "index_id": "idx_bh7fgXWJMaa3", "execution_id": "exn_dQ4vTnRy73Lc", "created_at": "2026-07-18 20:06:35", "size_bytes": 2184, "url": "https://prod-core-i1-export.s3.eu-west-1.amazonaws.com/dlv_c8KtQ1rPxZ92/idx_bh7fgXWJMaa3_2026-07-18_values.csv?X-Amz-Expires=900" } ] } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 404 | No such delivery or execution. | | 429 | API key usage-plan rate limit exceeded. | ### List a team's deliveries ```http GET /deliveries/{id} ``` Returns the full delivery records for every delivery the team owns. **`{id}` is the TEAM id, not a delivery id** — the same convention as `GET /workflows/{id}`, and the reason `PATCH`/`DELETE` on this same path mean something different (those take the delivery id). There is no route that fetches a single delivery by its own id. Returns `[]` when the team has no deliveries. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Team** id — not a delivery id. | **Request** ```bash curl -X GET "https://api.indexone.io/deliveries/33a36e74-b36a-4a50-9a99-edeff5144a43" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "dlv_c8KtQ1rPxZ92", "type": "delivery", "name": "Daily values to ops", "stage": "live", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "index_ids": [ "idx_bh7fgXWJMaa3" ], "timezone": "America/New_York", "created_at": "2026-06-12 15:41:55", "updated_at": "2026-07-14 10:02:33", "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_payload", "operation": "index_panel", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "payload_type": "values", "history": "latest", "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "filename_template": "{index_id}_{date}_values.csv" } }, { "id": "delivery_send", "operation": "send_email", "use_cache": false, "input": [ { "$ref": "delivery_payload" } ], "parameters": { "recipients": [ "ops@example.com" ], "subject": "Daily index values", "body": "Attached are today's closing values." } } ] }, { "id": "dlv_Q4mWs7Ld20Bk", "type": "delivery", "name": "Closing values to LSEG", "stage": "live", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "index_ids": [ "idx_bh7fgXWJMaa3" ], "timezone": "UTC", "created_at": "2026-07-02 09:12:40", "updated_at": "2026-07-02 09:12:40", "operations": [ { "id": "delivery_trigger", "operation": "index_event_trigger", "use_cache": false, "parameters": { "index_id": "idx_bh7fgXWJMaa3", "event_type": "value_eod" } }, { "id": "delivery_send", "operation": "send_refinitiv", "use_cache": false, "input": [ { "$ref": "delivery_trigger" } ], "parameters": { "index_ids": { "$ref": "delivery_trigger.output.fired_index_ids" }, "ric_map": { "idx_bh7fgXWJMaa3": ".SPLTPR" } } } ] } ] ``` | Status | Meaning | | --- | --- | | 400 | `team id required`. | | 401 | Missing or invalid id token. | | 403 | Caller is not a member of that team. | | 429 | API key usage-plan rate limit exceeded. | ### Update a delivery ```http PATCH /deliveries/{id} ``` Partially updates a delivery. **Here `{id}` is the DELIVERY id**, unlike the `GET` on this same path, which takes the team id. `id`, `type`, `team_id` and `created_at` are blacklisted and dropped. Sending a field with the value `null` **removes** that attribute from the row rather than setting it to null — this is how you clear an optional config block. Every patch notifies the coordinator to reload the delivery, because any field can change execution behaviour. Pausing a delivery is done by patching `stage`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Delivery** id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `stage` | "live" \| "draft" | no | Set to `draft` to pause the delivery. | | `index_ids` | array of string | no | | | `timezone` | string | no | | | `operations` | array of object | no | Replaces the delivery's operations. Send the whole list — a partial list is stored as-is and is what will run. | **Request** ```bash curl -X PATCH "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "stage": "draft" }' ``` **Response** ```json { "id": "dlv_c8KtQ1rPxZ92", "name": "Daily values to ops", "stage": "draft", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "updated_at": "2026-07-21 08:40:19", "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_7Bz4RmKt29Qd" } } } ``` | Status | Meaning | | --- | --- | | 400 | `id required`, or `no editable fields`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 429 | API key usage-plan rate limit exceeded. | ### Delete a delivery ```http DELETE /deliveries/{id} ``` Deletes the delivery row and unlinks it from the team, after telling the coordinator to stop scheduling it. **Here `{id}` is the DELIVERY id.** Unlike deleting a workflow, this purges no data — a delivery owns no value or holdings history, only its own definition and its execution rows. Files already sent are unaffected. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | **Delivery** id. | **Request** ```bash curl -X DELETE "https://api.indexone.io/deliveries/dlv_c8KtQ1rPxZ92" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "deleted": "dlv_c8KtQ1rPxZ92", "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_7Bz4RmKt29Qd" } } } ``` | Status | Meaning | | --- | --- | | 400 | `id required`. | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this delivery. | | 429 | API key usage-plan rate limit exceeded. | ## Datasets Team-owned tabular data (`dst_...`) stored as parquet in S3 and consumable from a workflow via the `load_dataset` operation. ### List datasets ```http GET /dataset ``` Lists the datasets belonging to a team, newest first. The datasets table is keyed on `id` alone with no secondary index, so it cannot be queried by team. This reads the `datasets` map held on the team record — the same source the console list and the MCP `list_datasets` tool use — which is why `team_id` is required rather than inferred. Added 2026-07-21. Before that there was no collection route and callers had to read the team record themselves via `GET /teams/{id}`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `team_id` | string | no | Team to list for. Would default to the caller's primary team. | **Request** ```bash curl -X GET "https://api.indexone.io/dataset" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json [ { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "updated_at": "2026-07-21 08:50:42", "row_count": 4812 } ] ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller is not a member of that team. | | 429 | API key usage-plan rate limit exceeded. | ### Create a dataset ```http POST /dataset ``` Creates an empty dataset record (`dst_...`) and reserves its S3 prefix, then links it into the team's `datasets` map. The dataset holds no data yet: upload it with `POST /dataset/{dataset_id}/presigned_url` followed by `POST /dataset/{dataset_id}/mutation`. Once populated, the dataset is readable from a workflow through the `load_dataset` operation. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | | `team_id` | string | no | Owning team. | | `get_presigned_url` | boolean | no | Also return an upload URL in the same call. | **Request** ```bash curl -X POST "https://api.indexone.io/dataset" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Custom ESG scores", "description": "Quarterly vendor ESG scores keyed by FIGI", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43" }' ``` **Response** ```json { "dataset": { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "description": "Quarterly vendor ESG scores keyed by FIGI", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "created_at": "2026-07-21 08:45:10", "updated_at": "2026-07-21 08:45:10", "s3_url": "s3://prod-core-i1-datasets/dst_R4mVnQ8xL27p", "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p" }, "team_data": { "id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "datasets": { "dst_R4mVnQ8xL27p": { "id": "dst_R4mVnQ8xL27p" } } } } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Get a dataset ```http GET /dataset/{dataset_id} ``` Returns the dataset record. With `parsed=true` the current parquet file is read from S3 and its rows are attached as `data` — convenient for previews, but it loads the whole dataset into the response, so avoid it for large files. Note there is no ownership check on this route: any authenticated caller who knows a `dst_...` id can read it. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `parsed` | boolean | no | Also read and return the dataset rows as `data`. | **Request** ```bash curl -X GET "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "description": "Quarterly vendor ESG scores keyed by FIGI", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "created_at": "2026-07-21 08:45:10", "updated_at": "2026-07-21 08:50:42", "s3_url": "s3://prod-core-i1-datasets/dst_R4mVnQ8xL27p", "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 404 | No such dataset. The handler indexes the DynamoDB `Item` key unguarded, so an unknown id surfaces as a `500`-class gateway error rather than a clean 404. | | 429 | API key usage-plan rate limit exceeded. | ### Update dataset metadata ```http PATCH /dataset/{dataset_id} ``` Updates a dataset's metadata. Only `name` and `description` may be changed; everything else on the record is an identifier or derived from one (`s3_url` and `url` embed the dataset id), so changing them would orphan the stored objects. The team record holds a full copy of each dataset, written once at creation. This endpoint rewrites that copy too, so a rename is reflected everywhere the team map is read. Data changes still go through `POST /dataset/{dataset_id}/mutation`. Added 2026-07-21. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | | | `description` | string | no | | **Request** ```bash curl -X PATCH "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Custom ESG scores (v2)", "description": "Now including governance sub-scores" }' ``` **Response** ```json { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores (v2)", "description": "Now including governance sub-scores", "updated_at": "2026-07-21 09:01:00" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this dataset. | | 404 | No such dataset. | | 429 | API key usage-plan rate limit exceeded. | ### Delete a dataset ```http DELETE /dataset/{dataset_id} ``` Deletes a dataset: every S3 object under its prefix, its DynamoDB record, and its entry in the team's `datasets` map. Not reversible. Added 2026-07-21. The same purge remains available as `POST /dataset/{dataset_id}/mutation` with `{"mutation": "delete"}`, which is how deletion worked before this route existed — an overload that made a destructive action look like a data append. Prefer `DELETE`. Workflows referencing the dataset are not updated and will fail on their next run. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request** ```bash curl -X DELETE "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` **Response** ```json { "dataset_id": "dst_R4mVnQ8xL27p", "s3_deleted": 12, "record_deleted": true, "reference_removed": true } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Caller's teams do not own this dataset. | | 404 | No such dataset. | | 429 | API key usage-plan rate limit exceeded. | ### Apply an upload to a dataset ```http POST /dataset/{dataset_id}/mutation ``` Step two of the upload flow. Reads the file you uploaded and folds it into the dataset's current parquet. - `append` — concatenate the new rows onto the existing data. Columns are unioned, so a schema mismatch produces nulls rather than an error. - `replace` — discard the existing data and use the upload as the new dataset. - `delete` — **deletes the entire dataset**: every S3 object under its prefix, the DynamoDB record and the team link. `mutation_data_url` is ignored. This is the de-facto delete endpoint until `DELETE /dataset/{dataset_id}` exists. `mutation_data_url` is the presigned URL with its query string removed (or any `s3://` or `https://.s3.amazonaws.com/` URL). `.csv`, `.tsv` and `.parquet` are recognised by extension. On success a `dataset_update` trigger is posted so workflows consuming this dataset can react. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `mutation` | "append" \| "replace" \| "delete" | yes | | | `mutation_data_url` | string | no | URL of the uploaded file. Required for `append` and `replace`. | **Request** ```bash curl -X POST "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p/mutation" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "mutation": "append", "mutation_data_url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p/fil_7YtQ2nWx4Kp8/esg_scores_2026q2.csv" }' ``` **Response** ```json { "dataset": { "id": "dst_R4mVnQ8xL27p", "name": "Custom ESG scores", "team_id": "33a36e74-b36a-4a50-9a99-edeff5144a43", "updated_at": "2026-07-21 08:50:42", "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p" }, "trigger_notification": { "sent": true, "status_code": 200, "response": { "status": "accepted", "trigger_id": "trg_3Kw8PnBv52Hj" } } } ``` | Status | Meaning | | --- | --- | | 400 | `mutation type not specified`, or `unsupported mutation type: `. | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 404 | No such dataset. | | 429 | API key usage-plan rate limit exceeded. | ### Get an upload URL for dataset data ```http POST /dataset/{dataset_id}/presigned_url ``` Returns a presigned S3 `PUT` URL. Step one of the two-step upload: `PUT` your CSV or parquet bytes to the returned `url` with `Content-Type` set to exactly the `file_type` you requested (a mismatch makes S3 reject the signature), then call `POST /dataset/{dataset_id}/mutation` with the URL stripped of its query string to fold the upload into the dataset. The upload lands at a unique per-file key, so an upload alone never overwrites the current data. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | Dataset id. | **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `file_type` | string | yes | MIME type of the upload; becomes the required `Content-Type` of the PUT. | | `file_name` | string | no | Optional filename. Defaults to a generated `fil_...` name. | **Request** ```bash curl -X POST "https://api.indexone.io/dataset/dst_R4mVnQ8xL27p/presigned_url" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "file_type": "text/csv", "file_name": "esg_scores_2026q2.csv" }' ``` **Response** ```json { "url": "https://prod-core-i1-datasets.s3.amazonaws.com/dst_R4mVnQ8xL27p/fil_7YtQ2nWx4Kp8/esg_scores_2026q2.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=abc123", "key": "dst_R4mVnQ8xL27p/fil_7YtQ2nWx4Kp8/esg_scores_2026q2.csv", "bucket": "prod-core-i1-datasets", "dataset_id": "dst_R4mVnQ8xL27p", "content_type": "text/csv" } ``` | Status | Meaning | | --- | --- | | 401 | Missing or invalid id token. | | 403 | Missing or invalid `x-api-key`. | | 429 | API key usage-plan rate limit exceeded. | ### Validate a workflow or a dataset without running it ```http POST /validate ``` Two things can be validated, told apart by whether the body carries `dataset_id`. **Workflow** (no `dataset_id`) — checks the operation graph against the operation manifest: unknown operations, missing required parameters, broken `$ref` wiring, structural rules. **Dataset** (`dataset_id` present) — checks a dataset's data against its optional semantic-type schema. Pass `source` to check a file you have uploaded but not yet committed; omit it to report on the dataset as it stands. Reports unresolvable symbols (against the securities reference), unparseable dates, columns that differ from the stored data only by case, duplicate keys, out-of-range numbers and which dates are new — i.e. what will fire a `dataset_trigger`. **Advisory only.** This never blocks a commit and changes nothing; `ok: false` is information. Both shapes answer the same issues array (`severity`, `code`, `message`, `scope`). Column meanings come from the semantic type registry — `GET /schema` returns it as `semantic_manifest`. **Authentication** — `x-api-key: ` and `Authorization: ` (raw, no `Bearer ` prefix). **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | no | Validate this dataset. Omit to validate a workflow instead. | | `source` | string | no | s3 uri/key of an uploaded file to check BEFORE committing it. Omit to check the dataset's current data. | | `mutation` | "append" \| "replace" | no | How `source` would be committed. `append` compares against the stored rows, so new dates and duplicates are visible; `replace` judges the file on its own. | | `schema` | object | no | Column meanings to check against; defaults to the dataset's own. Omit on a dataset with no schema to get a `suggested_schema` back. | | `resolve` | boolean | no | Confirm identifiers against the securities reference. `false` checks shape only and is much faster. | | `preview_rows` | number | no | Rows of the incoming data to return with the report. | | `index_parameters` | object | no | Workflow to validate, when no `dataset_id` is given. | **Request** ```bash curl -X POST "https://api.indexone.io/validate" \ -H "x-api-key: YOUR_API_KEY" \ -H "Authorization: YOUR_ID_TOKEN" ``` | Status | Meaning | | --- | --- | | 400 | Unknown dataset, unreadable source file, or no dataset_id and no workflow. | ## Execution Engine Direct access to the execution engine: run an operation DAG, simulate a workflow, and read the live operation catalog. Unauthenticated at the gateway. ### Execute an operation DAG ```http POST /execute ``` Runs an arbitrary operation DAG on the execution engine and returns each operation's output. This is what the workflow builder calls to preview a node graph — nothing is persisted unless the DAG itself contains a persisting operation. **This route is unauthenticated at the API Gateway** (`AuthorizationType: NONE`, no API key required): it is proxied straight to the workflow service over a VPC link. Treat it accordingly. Pass `websocket_id` to stream per-node results as they complete over the WebSocket API; the HTTP response still returns the full result, truncated to 20 rows per output. Without it, outputs are returned untruncated. **The API Gateway integration timeout is 29 seconds.** A DAG that runs longer will have its HTTP connection cut even though the engine keeps going — which is why long runs must use `websocket_id`. As of 2026-07-21 this endpoint requires a team API key. It was previously reachable without any credential. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `operations` | array of object | no | The DAG to run. | | `websocket_id` | string | no | WebSocket connection id to stream node results to. Also accepted as `connection_id`. | | `team_id` | string | no | | **Request** ```bash curl -X POST "https://api.indexone.io/execute" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "operations": [ { "id": "trigger_1", "operation": "manual_trigger", "parameters": { "time": "2026-07-18 20:00:00" }, "input": [] }, { "id": "eod_1", "operation": "i1_core_eod", "parameters": {}, "input": [ { "$ref": "trigger_1" } ] } ] }' ``` **Response** ```json { "operations": [ { "id": "trigger_1", "operation": "manual_trigger", "state": { "error": false, "suspended": false }, "output": { "trigger_time": "2026-07-18 20:00:00" } }, { "id": "eod_1", "operation": "i1_core_eod", "state": { "error": false, "suspended": false }, "output": [ { "id": "BBG000B9XRY4", "time": "2026-07-18", "close": 233.18 } ] } ] } ``` | Status | Meaning | | --- | --- | | 400 | No `operations` array in the request body. | | 403 | Missing or invalid API key. | | 422 | The request body failed validation before the DAG was built. | | 500 | The DAG raised. `detail` is the exception message, or `{error, traceback}` when the service runs with tracebacks enabled. | ### Get the operation catalog ```http GET /schema ``` Returns the live operation catalog: every operation the execution engine can run, with its parameter schema, input and output contracts, and documentation. There are currently **68 operations**, covering triggers, core data (EOD prices, fundamentals, FX, corporate actions, quotes), dataset loading, transformation, statistics, optimisation, index construction, storage and delivery. This is the authoritative source when building a DAG — operation names, parameter names and enum values must come from here, not from memory, because the catalog changes as operations are added. The response is `{"operation_manifest": {: {...}}}`. It is a few hundred KB. **This route is unauthenticated** and takes no parameters. **Authentication** — none, this endpoint is public. **Request** ```bash curl -X GET "https://api.indexone.io/schema" ``` **Response** ```json { "operation_manifest": { "trigger": { "description": "Fire the flow on a cron schedule, optionally aligned to an exchange calendar session.", "category": "trigger", "parameters": { "cron": { "type": "string", "description": "5-field cron expression.", "required": true }, "timezone": { "type": "string", "default": "UTC" }, "exchange_calendar": { "type": "string", "description": "xcals calendar code." }, "alignment_enabled": { "type": "boolean", "default": false }, "align_time": { "type": "string", "enum": [ "session_open", "session_time", "session_close" ] } } }, "i1_core_eod": { "description": "Core end-of-day prices, market caps and volumes for the securities master.", "category": "core_data", "parameters": { "as_of": { "type": "string", "description": "Point-in-time selection date." }, "columns": { "type": "array", "items": { "type": "string" } } } } } } ``` | Status | Meaning | | --- | --- | | 500 | The engine failed to build the catalog. | ### Backtest a workflow definition ```http POST /simulate ``` Launches a historical simulation of an index definition and returns a pollable backtest id immediately — the same launch-now/fetch-later shape as the MCP `run_backtest` tool. The run replays the DAG over every scheduled date from `start_time` to now; its value series, holdings and statistics are persisted to a backtest blob. Nothing live is created and no schedule is registered. The body is `{"index_parameters": {...}}` — the same object `POST /workflows` would take. Fetch the result from `GET /backtests/{backtest_id}`, which waits server-side for the in-flight window so polling straight after this call is fine. Optionally pass `websocket_id` (or `connection_id`) to *also* stream progress frames over the WebSocket API as the run computes. Because the id comes back at once, the 29-second API Gateway integration timeout can never sever the launch. This route runs a backtest only — `stage` is forced to `backtest`; creating a live index is `POST /workflows`. Requires a team API key. **Authentication** — `x-api-key: `. **Request body** | Field | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | object | yes | The workflow definition: `name`, `start_time`, `exchange_calendar`, `operations`. | | `websocket_id` | string | no | Stream results to this WebSocket connection and return immediately. Also accepted as `connection_id`. | **Request** ```bash curl -X POST "https://api.indexone.io/simulate" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "index_parameters": { "name": "Example Momentum Index", "start_time": "2020-01-02 00:00:00", "exchange_calendar": "XNYS", "operations": [ { "id": "trigger_1", "operation": "trigger", "parameters": { "cron": "0 16 * * MON-FRI", "exchange_calendar": "XNYS" }, "input": [] }, { "id": "eod_1", "operation": "i1_core_eod", "parameters": {}, "input": [ { "$ref": "trigger_1" } ] }, { "id": "holdings_1", "operation": "create_index_holdings", "parameters": {}, "input": [ { "$ref": "eod_1" } ] } ] }, "websocket_id": "Yq3TneQMDoECJfw=" }' ``` **Response** ```json { "ok": true, "backtest_id": "bkt_9Fq2LmVt41Xe", "status": "running", "hint": "Poll GET /backtests/bkt_9Fq2LmVt41Xe until status is 'completed' or 'failed'." } ``` | Status | Meaning | | --- | --- | | 400 | No `index_parameters` object in the request body. | | 403 | Missing or invalid API key. | | 422 | The request body failed validation before the backtest was built. | | 500 | The backtest raised. | --- # Index One MCP tools ### `list_operations` List available workflow operations (id, name, description, category). Call with no args to see all + the category list; pass a category to narrow. Use this to discover real operations instead of guessing. START HERE if you have no other context: the working order is list_operations/list_examples to discover, get_example + get_operations to copy correct wiring, run_workflow to test against real data, then run_backtest to simulate and save_workflow/deploy_index to persist. The write tools validate and preview-run for you — build, don't guess. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `category` | string | no | Optional category filter, e.g. 'index_management'. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_operations", "arguments": { "category": "index_management" } } }' ``` ### `get_operations` Get the full parameter schema and output shape for specific operations. Always fetch an operation's spec before using it so parameters are correct. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `operation_ids` | array | yes | Operation ids to fetch, e.g. ['create_index_weighting']. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_operations", "arguments": { "operation_ids": [ "create_index_weighting" ] } } }' ``` ### `list_examples` List production-tested example workflows (id/name/description). Optional substring search. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `search` | string | no | Optional substring filter. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_examples", "arguments": { "search": "momentum" } } }' ``` ### `get_example` Fetch one or MORE example workflows to copy correct structure and wiring — pass several ids in 'example_ids' in a SINGLE call. Each returned example carries 'index_parameters_json' (a JSON string of the full workflow). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `example_ids` | array | no | One or more example ids to fetch in a single call. | | `example_id` | string | no | A single example id (prefer example_ids). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_example", "arguments": { "example_ids": [ "staggered_effective", "long_short_130_30" ] } } }' ``` ### `list_workflows` List saved workflows. scope='team' (the user's), 'public' (public/featured), or 'all'. When the user names a specific index/workflow, pass search=[] in ONE call — a workflow matching ANY fragment is returned, tagged with which matched, and fragments that hit nothing are listed back. The plain listing is sorted by name and cut at 'limit', so the one you need may not be in it (the response says when it was cut). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `scope` | "team" \\| "public" \\| "all" | no | Default 'team'. | | `search` | array | no | One or more case-insensitive fragments matched on name/description/id; put every candidate name in the same call. | | `limit` | integer | no | Default 50. | | `offset` | integer | no | Skip the first N (pagination). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_workflows", "arguments": { "scope": "public", "limit": 10 } } }' ``` ### `get_workflow` Fetch one workflow by id. Returns id/name/operation_ids plus 'workflow_json' (a JSON string of the full workflow's index_parameters, including its operations). Copy its operations ONLY when the user asks to edit, extend, or duplicate THAT workflow. To build an index ON another index (use its members/weights), read its stored holdings/universe at runtime instead of copying its operations — and never treat a name-similar workflow as a template: its operations implement its own request, not the current one. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | yes | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_workflow", "arguments": { "workflow_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `list_datasets` List the user's team datasets (id/name/description). Never invent dataset ids — use these. **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_datasets", "arguments": {} } }' ``` ### `inspect_dataset` Inspect a team dataset's real data. view='schema' (columns+dtypes), 'sample' (rows), 'shape', 'unique' (distinct values of 'column'), or 'stats' (per-column distribution summary). Use before filtering on it. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `dataset_id` | string | yes | | | `view` | "schema" \\| "sample" \\| "shape" \\| "unique" \\| "stats" | no | | | `column` | string | no | Required when view='unique'. | | `limit` | integer | no | | | `sample_rows` | integer | no | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "inspect_dataset", "arguments": { "dataset_id": "dst_9KcQm2", "view": "schema" } } }' ``` ### `run_workflow` Run a single operation, sub-workflow, or full workflow in PREVIEW mode (nothing is persisted; source data is real). Returns per-operation state and an output SUMMARY (columns, shape, small sample). Use it to test that a step works and to discover the real columns an operation produces, then inspect_run / get_column_values on the result. Omit 'operations' entirely to run the CURRENT CANVAS as-is (the cheap way to inspect the existing workflow's real data). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `operations` | string | no | JSON string: array of operation objects [{id, operation, parameters, input?}]. Omit to run the current canvas unchanged. | | `note` | string | no | Optional note about what you're testing. | **Behaviour** — read-only, idempotent, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_workflow", "arguments": { "operations": "[{\"id\":\"t\",\"operation\":\"manual_trigger\",\"parameters\":{}}]", "note": "smoke test" } } }' ``` ### `inspect_run` Introspect a run_workflow output — send it IN THE SAME TURN as that run_workflow and omit run_id (calls in one turn run in order against one session, so it reads the run just made); a later turn works too but costs an extra round trip. view='schema'|'sample'|'shape'|'unique'|'stats'. For 'unique' pass the 'column' (e.g. which sector values exist before filtering). 'stats' returns per-column count/nulls/mean/std/min/quartiles/max — use it to find which factor/score/weight column is skewed or dominates (pass 'column' for one, omit for all). Defaults to the most recent run and, if it has one operation, that operation. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `op_id` | string | no | | | `view` | "schema" \\| "sample" \\| "shape" \\| "unique" \\| "stats" | no | | | `column` | string | no | | | `limit` | integer | no | | | `sample_rows` | integer | no | | | `run_id` | string | no | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "inspect_run", "arguments": { "view": "stats" } } }' ``` ### `get_column_values` Distinct values of a column — from a dataset (dataset_id) or a run (op_id/run_id). The canonical way to discover real filter values (sectors, countries, ratings, ...). SEND IT IN THE SAME TURN as the run_workflow that produces the data and OMIT run_id: calls in one turn run in order against one session, so it reads the run just made. Waiting a turn to look up a column costs a whole extra round trip. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `column` | string | yes | | | `dataset_id` | string | no | | | `op_id` | string | no | | | `run_id` | string | no | | | `limit` | integer | no | | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_column_values", "arguments": { "column": "sector" } } }' ``` ### `validate_workflow` Validate a candidate workflow against the manifest (structure + wiring; nothing is executed). Returns structured issues. Optional early check while drafting — submit_workflow, run_backtest, deploy_index and save_workflow all validate automatically and return the same issues. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "validate_workflow", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `get_index` Get an index's metadata: name, description, stage/status, calendar settings, value series, and an operation summary. Works for your team's indices and public ones. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `get_index_values` Historical index value series (EOD rows with every value series, e.g. PR/TR). Optional ISO start_time/end_time bounds; long series are evenly downsampled to the point cap, always keeping the latest point. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `start_time` | string | no | Lower bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | Upper bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `limit` | integer | no | Max points returned (cap 5000). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_values", "arguments": { "index_id": "idx_bh7fgXWJMaa3", "start_time": "2024-01-01" } } }' ``` ### `get_index_holdings` Index holdings snapshot (constituents with shares, weights, divisor) at-or-before 'time' (default: latest). Pass 'limit' for the top-N by weight. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `time` | string | no | 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'; defaults to the latest snapshot. | | `limit` | integer | no | Top-N holdings by weight. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_holdings", "arguments": { "index_id": "idx_bh7fgXWJMaa3", "limit": 10 } } }' ``` ### `get_index_weightings` Index weighting snapshot at-or-before 'time' (default: latest). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `time` | string | no | 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'; defaults to the latest snapshot. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_weightings", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `get_index_universe` Index universe snapshot (eligible securities) at-or-before 'time' (default: latest). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index/workflow id (idx_...). | | `time` | string | no | 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'; defaults to the latest snapshot. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_universe", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `get_index_stats` Risk/return summary from the index's EOD value series: cumulative + annualized return, annualized volatility, max drawdown. 'series' picks a value column (default 'value'). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_id` | string | yes | Index id (idx_...) or backtest id (bkt_...). | | `start_time` | string | no | Lower bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | Upper bound, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `series` | string | no | Value series column, e.g. 'value' or a TR series id. | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_index_stats", "arguments": { "index_id": "idx_bh7fgXWJMaa3" } } }' ``` ### `run_backtest` Run a historical simulation of a workflow (index_parameters JSON). Validates AND runs the workflow once in preview first (structured issues + runtime errors with hints returned on failure — no separate validate_workflow or run_workflow call needed), then launches the run and immediately returns a backtest_id — poll get_backtest until status is 'completed' or 'failed'. Never creates a live index — use deploy_index. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | | `start_time` | string | no | Backtest start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to index_parameters.start_time). | | `end_time` | string | no | Backtest end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "run_backtest", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `get_backtest` Fetch a stored backtest result by backtest_id: performance summary and, with include_series=true, the value series evenly downsampled to max_points (default 500) for charting. Waits briefly server-side when the run is still in flight, so polling back-to-back is fine. After deploy_index, also reports the deployed live index id. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `backtest_id` | string | yes | Backtest id (bkt_...). | | `include_series` | boolean | no | Include the value series (downsampled). | | `max_points` | integer | no | Series point cap when include_series=true (default 500, max 2000). | **Behaviour** — read-only, idempotent. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_backtest", "arguments": { "backtest_id": "bkt_7Hs2Qa", "include_series": true } } }' ``` ### `save_workflow` Persist a workflow for the caller's team. Without workflow_id: creates a new DRAFT workflow. With workflow_id: updates an owned workflow in place (a live workflow's changed operations trigger a live reload). Always validated AND preview-verified first (runtime problems bounce with hints; verify=false skips the preview); never changes stage. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `index_parameters` | string | yes | JSON string of the full index_parameters object. | | `workflow_id` | string | no | Existing workflow id to update; omit to create a draft. | | `name` | string | no | Optional display name override. | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, **destructive**. **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "save_workflow", "arguments": { "index_parameters": "{\"name\":\"My Index\",\"start_time\":\"2019-12-28 00:00:00\",\"operations\":[]}" } } }' ``` ### `deploy_index` Create a LIVE index from a saved workflow_id or inline index_parameters: validates AND preview-verifies the workflow (runtime problems bounce with hints; verify=false skips the preview), then runs a full backtest, persists its history, and registers the index for continuous scheduled calculation. Requires confirm=true. Returns the pending index_id + backtest_id immediately — poll get_index(index_id) or get_backtest(backtest_id) until the index lands on 'live' or 'failed' (deployed_index_id appears in get_backtest when done). | Argument | Type | Required | Description | | --- | --- | --- | --- | | `workflow_id` | string | no | Saved workflow id to deploy. | | `index_parameters` | string | no | JSON string of index_parameters (alternative to workflow_id). | | `start_time` | string | no | History start, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'. | | `end_time` | string | no | History end, 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' (defaults to now). | | `confirm` | boolean | no | Must be true to actually deploy. | | `verify` | boolean | no | Default true: runs the full workflow once in preview to catch runtime problems (a column the upstream op doesn't output, filters matching nothing, runaway leverage, an empty index) and returns them as actionable issues before doing the work. Auto-skipped when this exact workflow already ran cleanly this session. Set false only to skip the preview deliberately. | **Behaviour** — **mutates state**, open-world (touches external data). **Direct JSON-RPC call** ```bash curl -X POST "https://api.indexone.io/mcp" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "deploy_index", "arguments": { "workflow_id": "idx_bh7fgXWJMaa3", "confirm": true } } }' ```