# Digital.ai Agility API Documentation — full corpus > The canonical developer documentation for the Digital.ai Agility API 131 pages, concatenated in site navigation order. Index: https://docs.digital.ai/agility-api/llms.txt # Digital.ai Agility API Documentation - URL: https://docs.digital.ai/agility-api/ - Markdown: https://docs.digital.ai/agility-api/index.md - Source: intro.md - Description: Developer reference for the Digital.ai Agility API: endpoints, query syntax, and the behaviour you can rely on. - Tags: overview, api, documentation This site is the developer reference for the Digital.ai Agility API: the endpoints, the query syntax they share, and the behavior you can rely on when building an integration. For Agility features, concepts, and other product help, see the [Digital.ai Agility Documentation](https://docs.digital.ai/agility/). ## What Is the Agility API? The Agility API gives programmatic access to the Agility asset model over HTTP. Use it to: - **Query data** — retrieve stories, defects, tasks and other assets - **Manage assets** — create and update work items - **Automate workflows** — change statuses, assign owners, move items - **Discover metadata** — learn about asset types and available attributes - **Perform bulk operations** — query, create, update and invoke operations in a single request ## Quick Links | Start here | What it covers | | --- | --- | | 🚀 [Getting started](/getting-started/quickstart) | Make your first authenticated API call. | | 🧭 [Choosing an API](/getting-started/choosing-an-api) | Decide between `query.v1`, `rest-1.v1`, `api/asset` and `meta.v1`. | | 🔐 [Authentication](/getting-started/authentication) | Access tokens, Basic authentication and the headers every request needs. | | 📖 [API reference](/apis) | Request and response detail for every Agility API surface. | | 💡 [Examples](/examples) | Working code in cURL, Python and TypeScript. | ## The Main API Surfaces :::tip[Add your instance URL and copy working requests] Code examples throughout this site use *~/* as a placeholder for your Agility instance base URL. Enter your base URL in the field at the top of any page and every *~/* on the page becomes your actual URL, so you can copy a request and run it as-is. Your instance URL stays in your browser's local storage and is never sent anywhere. For more information, see [Base URL](./apis/conventions/base-url). ::: ### Bulk API (`api/asset`) Query, create, update and invoke operations on many assets in a single request. The most capable single endpoint: it is the only one that mixes reads, writes and operations in one call. **Use cases**: mass imports, bulk status changes, data migrations, one-shot read-modify-write [Learn more about the Bulk API →](/apis/asset-bulk) ### Query API (`query.v1`) Read assets with flexible filtering, sorting and paging. Perfect for retrieving stories, defects and other work items. **Use cases**: story lookups, status reports, filtered queries [Learn more about the Query API →](/apis/query-v1) ### Meta API (`meta.v1`) Discover the asset types, attributes, relationships and operations available in your Agility instance. **Use cases**: dynamic form generation, validation, attribute discovery [Learn more about the Meta API →](/apis/meta-v1) ### REST API (`rest-1.v1`) A family of three namespaces sharing one URL shape: `/rest-1.v1/Data` for current data, `/rest-1.v1/Hist` for historical versions, and `/rest-1.v1/New` for a defaulted template for a new asset. Create and update individual assets, and send operations to change status, assign owners, and more. **Use cases**: creating stories, updating status, assigning work, reading audit history [Learn more about the REST API →](/apis/rest-1) Beyond these four, the [API reference](/apis) covers the full surface: attachments and images, localization, field visibility, the activity stream, rooms, and more. ## Core Concepts Understanding these concepts will help you work effectively with the API: - **[Assets, asset states and OIDs](/concepts/assets)** — work items, their lifecycle, and how assets are identified - **[Attributes](/concepts/attributes)** and **[relations](/concepts/relations)** — properties and the links between assets - **[Operations](/concepts/operations)** — actions that can be performed on assets - **[Scopes and projects](/concepts/scope-projects)** — how work is organized - **[Workflows and status](/concepts/workflows)** — moving items through your process ## Best Practices - **Always page list queries.** Agility has no default page size — an unpaged query returns the *entire* result set. See [query size and performance](/guides/query-size-and-performance). - **Select only the attributes you need.** An explicit selection keeps payloads small and your integration stable as the schema evolves. - **Prefer access tokens** over username/password, and never commit them to source control. - **Request JSON** with an `Accept: application/json` header where the surface supports it. - **Check the Meta API** to validate that attributes exist before using them. - **[Handle errors gracefully](/guides/error-handling)** — implement retry logic for transient failures. ## Need Help? - **[FAQ](/faq)** — answers to frequently asked questions - **[Troubleshooting](/troubleshooting)** — common issues and solutions - **[API Console](https://docs.digital.ai/agility/docs/developerlibrary/exercise-1-basic-use-of-the-api-console)** — interactive API explorer built into Agility - **[Community forums](https://community.digital.ai/)** — ask questions and share knowledge - **[Support](https://support.digital.ai/hc/en-us)** — contact the Digital.ai support team To learn what this site covers and how its content is verified, see [About this documentation](/reference/sources). --- Ready to get started? Head to the [getting started guide](/getting-started/quickstart) to make your first API call. --- # Getting Started with the Agility API - URL: https://docs.digital.ai/agility-api/getting-started/quickstart - Markdown: https://docs.digital.ai/agility-api/getting-started/quickstart.md - Source: getting-started/quickstart.md - Description: Learn how to authenticate and make your first API calls to Digital.ai Agility - Tags: getting-started, authentication, api, quickstart The Agility API gives programmatic access to Agility's asset model, enabling you to integrate with external systems, automate workflows, and build custom tools. ## Overview The Agility API lets you: - Query assets (Stories, Defects, Tasks, Tests, etc.) - Create and update assets - Execute operations (status changes, assignments) - Discover metadata about your Agility instance - Perform bulk operations for large-scale changes ## Quick Start ### 1. Get Your API Credentials Before you can make API calls, you need an access token: 1. Log into your Agility instance 2. Open the member menu (your avatar) and choose **Applications** 3. Add a personal application: give it a name and apply — the access token is displayed **once**, at creation 4. Copy the token and store it securely **Important**: Treat your access token like a password. Never commit it to source control. :::note An administrator can disable access tokens for an instance. If the Applications page will not issue one, ask your administrator — see [Authentication](/getting-started/authentication) for the full picture. ::: ### 2. Set up Your Environment Create environment variables for your credentials: ```bash # Your Agility instance URL (no trailing slash) export AGILITY_BASE_URL="https://www7.v1host.com/YourInstance" # Your personal access token export AGILITY_ACCESS_TOKEN="your-token-here" ``` ### 3. Test Your Connection Ask the instance who you are: ```bash curl "${AGILITY_BASE_URL}/rest-1.v1/Data/Member?where=IsSelf='true'&sel=Name&page=1,0" \ -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \ -H "Accept: application/json" ``` A `200` with your own Member record proves the token works; a bad token is a `401`. Don't test with [`meta.v1`](/apis/meta-v1) or `config.v1` — both answer **anonymously**, so they return `200` whether your token is valid or not, and a broken credential then surfaces later as a confusing failure on your first real call. ## Common Asset Types Agility uses internal names for assets that may differ from the UI labels. Here are the most common ones: | API Name | Common UI Name | | --------- | -------------------------------- | | `Story` | Story, Backlog Item, Requirement | | `Defect` | Defect, Bug | | `Task` | Task | | `Test` | Test | | `Scope` | Project | | `Timebox` | Iteration, Sprint | | `Epic` | Portfolio Item | | `Theme` | Backlog Group | | `Member` | Team Member | :::tip Your Agility instance may have customized labels. Use the Meta API to see exactly what's available in your environment. ::: ## Your First Query Let's query for a story by its number: ```json { "from": "Story", "select": ["Number", "Name", "Status.Name", "Scope.Name", "Owners.Name"], "where": { "Number": "S-01234" } } ``` Save this as `query.json` and execute: ```bash curl "${AGILITY_BASE_URL}/query.v1" \ -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d @query.json ``` ## Understanding Asset States Assets in Agility have states that control their visibility: | State | Value | Description | | ----------- | ----- | ------------------------------------------------------ | | Future | 0 | Not yet active | | Active | 64 | Currently active | | Closed | 128 | Closed — also called **Inactive** for many asset types | | Template | 200 | Workitem template (dead) | | Broken Down | 208 | Legacy: story broken into children (dead) | | Deleted | 255 | Deleted — still stored, and recoverable until purged | States of 192 and above are "dead": the UI excludes them by default, and API queries can do the same by filtering on `AssetState`. Two things trip people up: - **"Closed" and "Inactive" are the same state (128).** Which word you see depends on the asset type — workitems close, while lists, projects and members are inactivated. - **Deleted assets are not gone.** They remain queryable — see [troubleshooting](/troubleshooting) for how to include them — and can be recovered until an administrator purges them. ## Key Endpoints | Endpoint | Purpose | Authentication Required | | ----------------- | --------------------------------------------- | ----------------------- | | `/meta.v1` | Discover asset types, attributes, relations | No\* | | `/query.v1` | Query assets with filters and projections | Yes | | `/rest-1.v1/Data` | Query, create and update current assets | Yes | | `/rest-1.v1/Hist` | Read historical versions of assets | Yes | | `/rest-1.v1/New` | Get templates for new assets | Yes | | `/api/asset` | Query, create, update and execute in bulk | Yes | \*`meta.v1` is anonymous by design — the schema is not instance data. An on-premise installation that puts its own authentication in front of the whole site (for example at the web-server level) can still gate it. ## Required Headers Always include these headers when making API calls: ```http Authorization: Bearer YOUR_ACCESS_TOKEN Content-Type: application/json Accept: application/json ``` ## Next Steps Now that you have a working connection: 1. **[Explore the Meta API](/apis/meta-v1)** - Discover what's available in your instance 2. **[Learn Query Syntax](/apis/query-v1)** - Build powerful queries with filters and sorting 3. **[Authentication Guide](/getting-started/authentication)** - Understand security best practices 4. **[Code Examples](/examples/curl)** - See examples in cURL, Python, and TypeScript ## Common Pitfalls - **Mixed authentication modes**: Don't send both basic auth and bearer token - **XML vs JSON**: Always specify `Accept: application/json` to avoid XML responses - **Asset state confusion**: Remember that deleted assets still exist with state=255 - **Case sensitivity**: Attribute names are case-sensitive ## Getting Help - **API reference**: [REST API](/apis/rest-1), [Query API](/apis/query-v1) and the [query grammar](/reference/query-grammar) on this site - **Troubleshooting**: [diagnose by symptom](/troubleshooting) — most Agility traps return a successful response with the wrong data - **Community**: Visit [Digital.ai Community](https://community.digital.ai/) for discussions - **Support**: Contact [Digital.ai Support](https://support.digital.ai/hc/en-us) for technical issues ## Additional Resources - [REST API Tutorial](https://docs.digital.ai/agility/docs/developerlibrary/version-one-rest-api-tutorial) - Step-by-step exercises - [API Endpoints Reference](https://docs.digital.ai/agility/docs/developerlibrary/endpoints) - Complete endpoint list - [Sample Code](https://docs.digital.ai/agility/docs/sample-code) - Official code samples --- # Authentication Guide - URL: https://docs.digital.ai/agility-api/getting-started/authentication - Markdown: https://docs.digital.ai/agility-api/getting-started/authentication.md - Source: getting-started/authentication.md - Description: Access tokens, Basic authentication and the headers every Digital.ai Agility API request needs. - Tags: authentication, security, access-token Every Agility API endpoint requires authentication except `meta.v1`, which is anonymous by design — the schema is not instance data. The method for API clients is a **token** sent as a Bearer header: an Agility-issued personal access token, or on [OIDC-configured instances](#oidc-access-tokens) a token from the identity provider. Almost all customer instances authenticate members through **SSO — the recommended configuration for security** — via OIDC, or SAML where configured; API clients on those instances use tokens. ([Basic authentication](#basic-authentication) exists, but it is effectively a dev/demo setting; some on-premise instances instead use [Windows Integrated Authentication](#windows-integrated-authentication).) ## Access Token Authentication (Recommended) ### Step 1: Generate an Access Token 1. Log into your Agility instance 2. Open the member menu (your avatar) and choose **Applications** 3. Enter an application name (e.g., "My Integration") and select type **Personal** 4. Click **Add** 5. The access token is displayed **once** — copy it and store it securely. You cannot view it again after closing the popup. Agility-issued tokens always begin with `1.` followed by 28 characters of base64 — knowing the shape helps you spot a truncated paste. (Tokens issued by an identity provider on [OIDC-configured instances](#oidc-access-tokens) look different.) :::note An administrator can disable access tokens for an instance. If the Applications page will not issue one, ask your administrator. ::: ### Step 2: Store Your Token Securely **Environment variables** (recommended): ```bash # Linux/macOS export AGILITY_BASE_URL="https://www7.v1host.com/YourInstance" export AGILITY_ACCESS_TOKEN="your-token-here" # Windows PowerShell $env:AGILITY_BASE_URL="https://www7.v1host.com/YourInstance" $env:AGILITY_ACCESS_TOKEN="your-token-here" ``` Examples across this site assume those two variables. **Never** commit tokens to source control, share them in chat or email, store them in plain text in a codebase, or reuse one token across applications. ### Step 3: Include Authentication Headers ```http Authorization: Bearer YOUR_ACCESS_TOKEN Content-Type: application/json Accept: application/json ``` ## Code Examples ### cURL ```bash curl "${AGILITY_BASE_URL}/query.v1" \ -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "from": "Story", "select": ["Number", "Name"], "page": { "size": 50, "start": 0 } }' ``` ### Python ```python import os import requests class AgilityClient: def __init__(self): self.base_url = os.environ['AGILITY_BASE_URL'] self.token = os.environ['AGILITY_ACCESS_TOKEN'] self.headers = { 'Authorization': f'Bearer {self.token}', 'Content-Type': 'application/json', 'Accept': 'application/json' } def query(self, payload): response = requests.post( f'{self.base_url}/query.v1', headers=self.headers, json=payload, timeout=30 ) response.raise_for_status() return response.json() # Usage client = AgilityClient() result = client.query({ 'from': 'Story', 'select': ['Number', 'Name'], 'page': {'size': 50, 'start': 0} }) ``` ### TypeScript ```typescript class AgilityClient { private baseUrl: string; private token: string; private headers: HeadersInit; constructor(baseUrl: string, token: string) { this.baseUrl = baseUrl; this.token = token; this.headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }; } async query(payload: unknown): Promise { const response = await fetch(`${this.baseUrl}/query.v1`, { method: 'POST', headers: this.headers, body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(`Query failed: ${response.status}`); } return response.json(); } } // Usage const client = new AgilityClient(process.env.AGILITY_BASE_URL!, process.env.AGILITY_ACCESS_TOKEN!); const result = await client.query({ from: 'Story', select: ['Number', 'Name'], page: { size: 50, start: 0 }, }); ``` ## Basic Authentication (Dev and Demo Instances Only) {#basic-authentication} Basic authentication works only where Agility itself stores the member's password — which in practice means development and demo instances. Almost all customer instances authenticate through SSO, the recommended configuration for security reasons, and there Basic does not apply. Treat it as a local convenience, never an integration strategy: ```bash # suitable for a local dev instance only curl -u "username:password" "${AGILITY_BASE_URL}/rest-1.v1/Data/Story?sel=Name&page=1,0" ``` Even where it works, prefer tokens: a leaked password exposes the whole account and every session, while a leaked token can be revoked on its own. ## Windows Integrated Authentication (On-Premise Only) {#windows-integrated-authentication} On-premise installations on **Enterprise and Ultimate editions** can be configured to authenticate members through **Windows Integrated Authentication (NTLM)** instead of Agility's own login. An administrator selects it during installation, when IIS has the Windows Authentication component. On these instances IIS turns off anonymous access for `rest-1.v1`, so a Bearer token sent there gets an NTLM challenge and never reaches Agility. Send access-token requests for `rest-1.v1` to `~/rest-1.oauth.v1` instead (the SDKs do this with `UseOAuthEndpoints`). `query.v1` and `api/asset` accept the token at their usual paths. [Basic authentication](#basic-authentication) isn't available on these instances, so use [Access Token Authentication](#access-token-authentication-recommended) for API clients. ## OIDC Access Tokens Instances configured for OIDC single sign-on also accept access tokens issued by **the identity provider Agility is directly configured against** as `Bearer` credentials — an alternative to Agility-generated tokens in those environments. In most OIDC configurations the authentication is **federated**: the Digital.ai platform sits between Agility and your organization's own identity provider. In that arrangement, the tokens that work are the ones issued by the **Digital.ai platform** — tokens issued directly by your organization's identity provider are *not* accepted. Whether this applies to your instance, and how to obtain a working token, depends on the identity configuration; ask your administrator. ## Token Lifecycle **Tokens do not expire.** A token stays valid until you revoke it or an administrator disables access tokens for the instance — which makes deliberate lifecycle habits more important, not less: - Name tokens for what uses them ("CI/CD Pipeline", "Local Development") so revocation is surgical - Create separate tokens per application - Rotate on a schedule of your choosing, and immediately when a team member leaves or a token may have been exposed: create the new token, switch your secrets over, verify, then revoke the old one **A token is not a scoped credential.** It always acts with the full rights and privileges of the member it belongs to — Agility currently offers no way to narrow an access token's reach. To limit what an integration can do, run it as a dedicated member whose role grants only what it needs — see [Roles and permissions](/reference/roles-and-permissions). **To revoke a token**: member menu → **Applications** → remove the application entry. Revocation is immediate. ## A Note on OAuth 2.0 The Agility API has **never supported OAuth 2.0** — despite naming, in old endpoint paths and internal machinery, that suggests otherwise. You may still see paths like `rest-1.oauth.v1` in older material. They were never OAuth 2.0: they existed so that access tokens could authenticate on instances using delegated SSO. Use the standard endpoints with a Bearer token instead, except on instances using [Windows Integrated Authentication](#windows-integrated-authentication). (The broader [Digital.ai Platform API](https://docs.digital.ai/platform/docs/api/api-overview) is a separate product surface with its own authentication.) ## Testing Authentication ```bash # meta.v1 needs no credentials — a 200 here proves the URL is right curl "${AGILITY_BASE_URL}/meta.v1/Story?accept=application/json" \ -H "Accept: application/json" # a 200 here proves the token works curl "${AGILITY_BASE_URL}/query.v1" \ -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"from":"Story","select":["Number"],"page":{"size":1,"start":0}}' ``` Testing the two in that order separates "wrong URL" from "bad credentials" — the two most common setup failures. ## Common Authentication Errors | Error | Likely cause | What to check | | ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------- | | `401 Unauthorized` | Missing or invalid token; access tokens disabled instance-wide | The `Authorization` header, the token value, and — if both look right — whether your administrator has disabled tokens | | `403 Forbidden` | Authenticated, but your member's role lacks the right | The member's role and project access | ## Security Best Practices ### ✅ Do - Store tokens in environment variables or a secret manager - Use separate tokens for dev, staging and production - Rotate tokens on a schedule — nothing expires them for you - Revoke immediately on any suspicion of exposure - Run integrations as a dedicated member with a minimal role - Use HTTPS for every request ### ❌ Don't - Commit tokens to source control, or share them in chat or email - Reuse one token across applications - Put credentials in URLs or query strings - Assume a token is limited — it carries the member's full rights and privileges ## Troubleshooting ### "401 with a Correct-Looking Token" Check the header shape — the space between `Bearer` and the token matters, and the token itself must start with `1.`: ```text Authorization: Bearer 1.abc123... ``` An Agility-issued token always starts with `1.` — anything else is most often a truncated or mangled paste (or, on OIDC-configured instances, an identity-provider token, which is legitimate). ### "Works in Browser but Not in Code" The browser is riding a cookie-based session from the UI login. Code must send explicit credentials on every request. ## Related Topics - [Quickstart](/getting-started/quickstart) — first API call - [Roles and permissions](/reference/roles-and-permissions) — what a member's role allows - [Error handling](/guides/error-handling) — handling auth errors in code --- # Choosing an API - URL: https://docs.digital.ai/agility-api/getting-started/choosing-an-api - Markdown: https://docs.digital.ai/agility-api/getting-started/choosing-an-api.md - Source: getting-started/choosing-an-api.md - Description: Decide between query.v1, rest-1.v1, api/asset and meta.v1 for your Agility integration. - Tags: getting-started, api, query, rest, bulk, meta Agility exposes several API surfaces. They overlap, and how you query — tight projections, real filters, explicit paging — matters more than which one you pick. But each surface has capabilities the others lack, and starting on the right one saves rework later. This page is the decision point: read it before you write any code. Typically start with `api/asset` and `meta.v1`: - `api/asset` is the one-stop shop: its query command carries the same document structure as `query.v1`, and the same surface creates, updates and invokes operations — one asset at a time or in batches. - `meta.v1` tells you what the schema looks like before you touch it. Reach for the other surfaces when they offer something `api/asset` does not. | If you want to… | Use | | ----------------------------------------------------------------------------------- | ------------------------------- | | Query, create, update and run operations — most data work | [`api/asset`](/apis/asset-bulk) | | Discover asset types, attributes, relations, operations | [`meta.v1`](/apis/meta-v1) | | Read history, read as of a past moment, or get a defaulted template for a new asset | [`rest-1.v1`](/apis/rest-1) | | Read with a leaner response shape | [`query.v1`](/apis/query-v1) | | Localize labels and terminology | [`loc-2.v1`](/apis/loc-2-v1) | | React to changes instead of polling | [Webhooks](/apis/webhooks) | ## Decision Table | Dimension | `api/asset` | `meta.v1` | `rest-1.v1` | `query.v1` | | ------------------ | -------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Primary purpose | One-stop shop: query, create, update, run operations | Describe the schema | Read/write individual assets, history, templates | Read assets | | Direction | Read and write | Read only | Read and write | Read only | | Payload style | YAML or JSON commands (query/create/update/execute) | JSON/XML schema documents | URL-addressed asset + JSON or XML body | JSON query document (`from`/`select`/`where`/…) | | Filtering power | The same query document as `query.v1`, embedded as its query command | N/A | Full filter grammar via `where`/`filter`; flat attribute-path selection via `sel` | `where`, `filter`, `sort`, `page`, `asof`, plus nested relation projection | | Writes per request | Many assets | None | One asset | None | | Relations | Traverse and project in queries; set in writes | Describes relations, doesn't return data | One hop at a time | Traverse and project nested relations | | Historical data | Yes (`asof` in the query command) | No | Yes (the `/rest-1.v1/Hist` namespace, `asof`) | Yes (`asof`, moments) | | Typical latency | One round trip per batch | Cacheable, changes rarely | One round trip per asset | One round trip for a whole graph | | Best for | Most integrations: imports, migrations, read-modify-write, mass changes | Dynamic forms, validation, code generation | History audits, new-asset templates, XML clients | Lean read-only feeds and dashboards | | Avoid when | You need a defaulted new-asset template | You need actual asset data | You are touching hundreds of assets in a loop | You need to write, or need response metadata such as total counts | ## How to Choose, in Order 1. **Start with `api/asset`.** Its query command gives you the full query document — filtering, sorting, paging, relation projection — and the same surface creates, updates and runs operations, singly or in batches. It is the only surface that mixes reads, writes and operations in one request. 2. **Pair it with `meta.v1`.** Learn the schema first, cache the result, and validate against it. Attribute names differ between instances because of custom fields. 3. **Reach for `rest-1.v1` when you need what `api/asset` does not offer**: the `/rest-1.v1/Hist` namespace to enumerate an asset's full change history, `/rest-1.v1/New` for a defaulted new-asset template, or an XML payload. (Point-in-time reads need no switch — `asof` works in `api/asset` and `query.v1` too.) 4. **Use `query.v1` when a leaner response is worth it.** It shares its query semantics with `api/asset`'s query command and returns a smaller response structure — but that response omits metadata such as total counts, which matters as soon as you page through a large result set. 5. **Do you need to know when something changed?** Prefer [webhooks](/apis/webhooks) over polling on a timer. The same decision as a picture: ```mermaid flowchart TD Start([What do you need?]) --> Notify{React to changes
as they happen?} Notify -- yes --> WH[webhooks] Notify -- no --> Schema{Discover types,
attributes, operations?} Schema -- yes --> Meta[meta.v1] Schema -- no --> Special{Change history, new-asset
template, or XML?} Special -- yes --> R1[rest-1.v1] Special -- no --> Lean{Read-only feed where a
lean response matters?} Lean -- yes --> Q1[query.v1] Lean -- no --> AA[api/asset — the default] ``` :::tip Most integrations need only two surfaces: `meta.v1` once at startup to learn the schema, and `api/asset` for everything else. Adding `rest-1.v1` for history or templates, or `query.v1` for lean read feeds, is normal — but they are additions, not the starting point. ::: :::warning Do not use `rest-1.v1` in a loop as a substitute for `api/asset`. It multiplies round trips, and every request carries the full authentication and processing overhead. Note that `api/asset` is not one atomic transaction — it deliberately breaks the work into several, which is what stops a large import from blocking other users. See [Performance](/guides/performance#what-batching-actually-saves). ::: ## Related Topics - [Quickstart](/getting-started/quickstart) — make your first authenticated call - [Authentication](/getting-started/authentication) — access tokens, Basic authentication and headers - [Assets](/concepts/assets) and [Attributes](/concepts/attributes) — the data model - [Payload patterns](/guides/payload-patterns) — request shapes that work well --- # Assets in Agility - URL: https://docs.digital.ai/agility-api/concepts/assets - Markdown: https://docs.digital.ai/agility-api/concepts/assets.md - Source: concepts/assets.md - Description: Understanding assets, asset types, OIDs and the core data model in Digital.ai Agility - Tags: concepts, assets, data-model Assets are the fundamental building blocks of Digital.ai Agility. Every work item, project, sprint and configuration element is an asset with a unique identifier, a type, and a set of attributes. ## What Is an Asset? An **asset** is any entity stored in the Agility system: - **Work items**: Stories, Defects, Test Sets, Tasks, Tests - **Portfolio items**: Epics (displayed as *Portfolio Item*), Themes (displayed as *Backlog Group*) - **Organization and planning**: Scopes (*Projects*), Timeboxes (*Iterations/Sprints*), Teams, Members - **Tracking**: Issues, Requests, Goals Each asset has: - A unique [**Object ID (OID)**](#object-ids-oids) such as `Story:1207` - An [**asset type**](#the-asset-type-hierarchy) that defines its structure and behaviour - A set of [**attributes**](/concepts/attributes) that store its data, including [**relations**](/concepts/relations) to other assets ## The Asset Type Hierarchy Asset types inherit from one another. The work-item corner of the hierarchy: ```mermaid flowchart TD BaseAsset --> Workitem["Workitem (abstract)"] BaseAsset --> Issue BaseAsset --> Request BaseAsset --> More["… many more"] Workitem --> PrimaryWorkitem["PrimaryWorkitem (abstract)"] Workitem --> Task Workitem --> Test Workitem --> Epic["Epic (UI: Portfolio Item)"] Workitem --> Theme["Theme (UI: Backlog Group)"] PrimaryWorkitem --> Story PrimaryWorkitem --> Defect PrimaryWorkitem --> TestSet ``` Two things about this tree surprise people: - **`Issue` is not a work item.** It descends directly from `BaseAsset`, so a `Workitem` query never returns Issues. - **`Epic` and `Theme` are.** Both descend from `Workitem`, so a `Workitem` query returns portfolio items alongside stories and tasks. Discover any type's parent with the Meta API: `GET /meta.v1/Story` reports `Base: PrimaryWorkitem`. :::tip Even the schema is assets The model describes itself: asset types and their attribute definitions are assets too. `AttributeDefinition` is a queryable type, and filtering it on its `AssetType` relation returns a type's attribute definitions through `query.v1`, `rest-1.v1` or `api/asset`. It works, but it is not the recommended path — `meta.v1` serves the same information already shaped for the purpose: inheritance resolved, operations included, no authentication required. ::: ### Querying Abstract Types Is Allowed — and Useful `Workitem` and `PrimaryWorkitem` are abstract: no asset is ever *just* a Workitem, and you cannot create one. But you can absolutely **query** them, and it is the natural way to work across subtypes: ```json { "from": "PrimaryWorkitem", "select": ["Number", "Name"], "page": { "size": 50, "start": 0 } } ``` That one query returns Stories, Defects and Test Sets together — the classic "whole backlog" shape. Remember the tree when interpreting results: a `Workitem` query includes Epics and Themes too. ## Object IDs (OIDs) Every asset has a unique **Object ID**: the asset type and a numeric id, joined by a colon. ```text Story:1207 Defect:5678 Scope:0 Member:20 ``` An OID with a **third** part (`Story:1207:5837`) identifies a past version — a *moment* — of the asset. See [Moments and history](/concepts/moments-and-history). ### OID Vs Number Work items also have a human-readable `Number`: | Property | Example | Description | | ---------- | ------------ | --------------------------------- | | **OID** | `Story:1207` | Permanent identifier — use in API calls | | **Number** | `S-01001` | Display number — use with humans | | **Name** | `Logon` | Title | Find an asset's OID from its Number (every result carries `_oid`): ```json { "from": "Story", "where": { "Number": "S-01001" }, "select": ["Number", "Name"] } ``` Fetch a specific asset when you already hold its OID — filter on `ID`, the attribute every asset has that refers to itself: ```json { "from": "Story", "where": { "ID": "Story:1207" }, "select": ["Number", "Name"] } ``` ## Asset State and Status — Two Different Things Two mechanisms track "where an asset is", and conflating them causes real bugs: - **`AssetState`** is the system lifecycle: Future (0), Active (64), Closed/Inactive (128), and the dead states (192 and above, including Deleted at 255). Every asset has it, it is not customizable, and it is what `Inactivate`/`Reactivate` operations change. See the [state table in the quickstart](/getting-started/quickstart#understanding-asset-states). - **`Status`** is the workflow position — a list value like *Future*, *In Progress*, *Done* — customizable per instance and per project. See [Workflows and status](/concepts/workflows). A story with `Status` = *Done* is usually still `AssetState` = Active; it becomes Closed when someone closes it. Lifecycle transitions are performed with **operations**, invoked as a query-string parameter with an empty POST body: ```bash curl -X POST -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \ "${AGILITY_BASE_URL}/rest-1.v1/Data/Story/1207?op=Inactivate" ``` See [Operations](/concepts/operations) for the catalogue and their quirks. ## Relations Between Assets Relations are attributes whose values are other assets. They come in two shapes: - **Single-value**: `Scope`, `Timebox`, `Status`, `Parent`, `Super` - **Multi-value**: `Owners`, `Children`, `Dependencies`, `Dependants` Frequently used work-item relations: | Relation | Cardinality | Description | | -------------- | ----------- | -------------------------------------------------- | | `Scope` | single | The project that contains the asset | | `Timebox` | single | Sprint/iteration assignment | | `Owners` | multi | Assigned members — note the plural | | `Children` | multi | A primary workitem's Tasks and Tests | | `Parent` | single | A Task's or Test's primary workitem | | `Super` | single | A primary workitem's Epic (portfolio parent) | | `Dependencies` | multi | Upstream work this item depends on | Traverse relations in a selection with dotted attribute paths: ```json { "from": "Story", "select": ["Name", "Scope.Name", "Timebox.Name", "Owners.Name"], "page": { "size": 50, "start": 0 } } ``` See [Relations](/concepts/relations) for traversal depth, filtering through relations, and downcasting. ## Querying Assets The response to a `query.v1` request is an **array of result arrays** — one per query in the request, each containing the matching assets: ```json [ [ { "_oid": "Story:1207", "Number": "S-01001", "Name": "Logon" }, { "_oid": "Story:1208", "Number": "S-01002", "Name": "Setup" } ] ] ``` Filter with `where` (exact match) and `filter` (full comparison grammar, including OR-lists): ```json { "from": "Story", "where": { "Scope": "Scope:0" }, "filter": ["Status.Name='Future','Done'"], "select": ["Number", "Name", "Status.Name"], "page": { "size": 50, "start": 0 } } ``` See the [Query API](/apis/query-v1) and the [query grammar](/reference/query-grammar) for the full syntax. ## Best Practices 1. **Use OIDs in code, Numbers with people.** 2. **Query abstract types** (`PrimaryWorkitem`) to work across subtypes; remember you cannot create them. 3. **Always page list queries** — Agility has no default page size. See [query size and performance](/guides/query-size-and-performance). 4. **Select only what you need**; every relation you project costs a join. 5. **Check `meta.v1`** when an attribute or type surprises you — the schema, including custom attributes, differs per instance. ## Code Examples ### TypeScript: Fetch One Asset by OID ```typescript async function fetchAsset(client: AgilityClient, oid: string) { const [results] = await client.query({ from: oid.split(':')[0], where: { ID: oid }, select: ['Number', 'Name'], }); if (results.length === 0) { throw new Error(`Asset ${oid} not found`); } return results[0]; } const story = await fetchAsset(client, 'Story:1207'); console.log(`${story.Number}: ${story.Name}`); ``` ### Python: OID Helpers ```python def parse_oid(oid: str) -> tuple[str, int]: """Split an OID into asset type and numeric id.""" asset_type, _, num = oid.partition(':') return asset_type, int(num) def build_oid(asset_type: str, asset_id: int) -> str: return f"{asset_type}:{asset_id}" asset_type, asset_id = parse_oid("Story:1207") ``` ## Related Topics - [Attributes](/concepts/attributes) — attribute types and definitions - [Relations](/concepts/relations) — traversal and filtering through the graph - [Moments and history](/concepts/moments-and-history) — past versions and `asof` - [Operations](/concepts/operations) — actions that change lifecycle state --- # Attributes in Agility - URL: https://docs.digital.ai/agility-api/concepts/attributes - Markdown: https://docs.digital.ai/agility-api/concepts/attributes.md - Source: concepts/attributes.md - Description: Understanding asset attributes, attribute definitions and data types in Digital.ai Agility - Tags: concepts, attributes, data-model Attributes are the named properties that hold an [asset's](/concepts/assets) data — names, estimates, dates, statuses, and [relations](/concepts/relations) to other assets. Everything you select, filter, or update in the API is an attribute. ## The Attribute Definition Every attribute is described by an **attribute definition** in the [Meta API](/apis/meta-v1). This is what `GET /meta.v1/Story` returns for `Story.Owners`: ```json { "_type": "AttributeDefinition", "Name": "Owners", "Token": "Story.Owners", "AttributeType": "Relation", "IsReadOnly": false, "IsRequired": false, "IsMultivalue": true, "IsCanned": true, "IsCustom": false, "Base": { "tokenref": "Workitem.Owners" }, "RelatedAsset": { "nameref": "Member" }, "ReciprocalRelation": { "tokenref": "Member.OwnedWorkitems" } } ``` The fields that drive integration decisions: | Field | Tells you | | -------------------- | ------------------------------------------------------------------ | | `Token` | The full name (`AssetType.Attribute`) — unambiguous in any context | | `AttributeType` | The data type — see [attribute types](/reference/attribute-types) | | `IsMultivalue` | Whether values are collections | | `IsRequired` | Must be supplied when creating the asset | | `IsReadOnly` | Maintained by the system; writes are rejected or ignored | | `IsCustom` | Defined by an administrator rather than shipped with the product | | `Base` | The ancestor type that actually declares the attribute | | `RelatedAsset` | For relations: the asset type on the other end | | `ReciprocalRelation` | For relations: the same link seen from the other side | ## Attribute Types, Briefly The [attribute-types reference](/reference/attribute-types) is the complete list; the ones you will meet constantly: | Type | Example on `Story` | Notes | | ---------- | --------------------------- | ------------------------------------------------------------------ | | `Text` | `Name`, `Number` | Single-line strings | | `LongText` | `Description` | Rich/long content | | `Numeric` | `Estimate` | Integer or decimal | | `Date` | `CreateDateUTC`, `ChangeDateUTC` | Prefer the `…UTC` twins for filtering — no timezone/DST surprises | | `Boolean` | `IsDeleted`, `IsClosed` | True/false | | `State` | `AssetState` | System lifecycle; read-only — change it with [operations](/concepts/operations) | | `Rank` | `Order` | An **opaque ordering token** — sort by it, never read or compare it | | `Relation` | `Scope`, `Owners`, `Status` | Single- or multi-value links to other assets | Two type-specific traps worth knowing early: - **`Rank` values are not numbers you can reason about.** Sort by `Order` to reproduce the UI's ordering; do not store, compare or do arithmetic on the values. - **Date attributes come in local and UTC pairs** (`ChangeDate` / `ChangeDateUTC`). Filter and project the UTC ones in integrations. ## Selecting Attributes Name attributes directly, traverse relations with dots, and project multiple attributes of related assets with a nested subquery. Multi-value paths come back as arrays. This request and its real response: ```json { "from": "Story", "where": { "ID": "Story:1207" }, "select": [ "Name", "Scope.Name", "Owners.Name", { "from": "Children:Task", "select": ["Name", "ToDo"] } ] } ``` ```json [ [ { "_oid": "Story:1207", "Name": "Logon", "Scope.Name": "Release 1.0", "Owners.Name": ["Alfred Smith"], "Children:Task": [ { "_oid": "Task:1469", "Name": "Design Login Page", "ToDo": "0" }, { "_oid": "Task:1470", "Name": "Code Login", "ToDo": "0" } ] } ] ] ``` Note the response keys: a dotted selection arrives **flattened under its path string** (`"Scope.Name"`), and a nested subquery arrives as an array of objects under its `from` expression (`"Children:Task"` — the `:Task` is a [downcast](/reference/query-grammar/attribute-paths) selecting only the Task children). ## Filtering by Attributes `where` matches exact values; `filter` takes the [full comparison grammar](/reference/query-grammar/filter-expressions), with values quoted: ```json { "from": "Story", "where": { "Scope": "Scope:0" }, "filter": ["Estimate>'2'", "ChangeDateUTC>='2026-01-01'"], "select": ["Number", "Name", "Estimate"], "page": { "size": 50, "start": 0 } } ``` Filter on OIDs rather than names where you can — names are editable and localized; OIDs are stable. ## Setting Attribute Values Writes happen on two surfaces, each with its own shape — don't mix them: **`rest-1.v1`** takes XML, marking each attribute with an action: ```xml Updated story name ``` See [update examples](/apis/rest-1/update-examples) for scalars, single relations, and multi-relation add/remove. **`api/asset`** takes JSON or YAML update commands: ```json { "from": "Story:1207", "update": { "Name": "Updated story name", "Owners": { "add": ["Member:20"] } } } ``` See [updating assets](/apis/asset-bulk/updating-assets) for the full command shape, including multi-relation `add`/`remove` semantics. ## Required and Read-Only Attributes Read `IsRequired` and `IsReadOnly` from the Meta API rather than memorizing lists. Verified examples from `Story`: - `Name` — `IsRequired: true`; `Scope` — `IsRequired: true`. A Story create without both fails. - `Number`, `CreateDateUTC`, `ChangeDateUTC` — `IsReadOnly: true`, generated and maintained by the system. - `AssetState` — read-only **and** only changeable through [operations](/concepts/operations) (`Inactivate`, `Reactivate`, …), never by direct update. One timing subtlety: requiredness is enforced **when values are written, not retroactively** — changing an attribute definition to required does not fill it in on existing assets of that type. Those assets are not locked by the missing value: updates to their other attributes succeed, and `Inactivate` closes them with the required attribute still empty. ## Custom Attributes Administrators can add attributes to most types. They appear in the Meta API like any other definition, flagged `IsCustom: true`; admin-created fields have tokens beginning `Custom_`. Because they differ per instance: 1. **Discover them from `meta.v1`** rather than hard-coding names 2. **Use the exact token**, including the `Custom_` prefix 3. **Check `AttributeType` and `IsRequired`** before writing 4. **Ask for them explicitly.** Custom fields are never part of the default selection, so a query that omits `sel` — or one that filters with `where` but does not name them in `sel` — returns none of them. List each `Custom_…` token you want (`sel=Name,Custom_Region`). ## Best Practices 1. **Select only what you need** — every attribute costs something and every relation costs a join; see [query size and performance](/guides/query-size-and-performance). 2. **Read the definition before you write the value** — `IsReadOnly`, `IsRequired` and `AttributeType` answer most "why did my update fail?" questions before they happen. 3. **Use `Token` names in logs and errors** (`Story.Name`, not `Name`) — the same attribute name means different things on different types. 4. **Prefer UTC date attributes** in filters and projections. ## Related Topics - [Attribute types](/reference/attribute-types) — the complete type list - [Attribute paths](/reference/query-grammar/attribute-paths) — dots, downcasts and path filters - [Meta API](/apis/meta-v1) — discovering definitions - [Relations](/concepts/relations) — the relation side of the model in depth --- # Relations in Agility - URL: https://docs.digital.ai/agility-api/concepts/relations - Markdown: https://docs.digital.ai/agility-api/concepts/relations.md - Source: concepts/relations.md - Description: Understanding relation attributes - the links that form the Agility asset graph - and how to traverse, filter and modify them - Tags: concepts, relations, data-model Relations are [attributes](/concepts/attributes) whose values are other assets. They form the graph that makes Agility's model expressive — and the query surfaces let you traverse that graph in a single request instead of stitching results together client-side. ## Two Shapes - **Single-value** — the asset links to at most one other asset: `Scope`, `Status`, `Priority`, `Team`, `Timebox`, `Parent`, `Super` - **Multi-value** — the asset links to any number of others: `Owners`, `Children`, `Dependencies`, `Dependants`, `Requests`, `AffectedByDefects` What they look like in a `query.v1` response — selecting a relation bare returns an OID object (or an array of them); selecting *through* it with a dot returns a **separate, flattened key**, not a merged object: ```json // select: ["Scope", "Scope.Name", "Owners"] [ [ { "_oid": "Story:1207", "Scope": { "_oid": "Scope:1308" }, "Scope.Name": "Release 1.0", "Owners": [{ "_oid": "Member:1035" }] } ] ] ``` To project several attributes of related assets as grouped objects, use a nested subquery — see [Attributes](/concepts/attributes#selecting-attributes). ## Frequently Used Work-Item Relations | Relation | Value | On | Meaning | | ------------------- | ------------ | ----------------- | ------------------------------------------ | | `Scope` | single | work items | The containing project | | `Status` | single | work items | Workflow list value | | `Priority` | single | primary workitems | Priority list value | | `Team` | single | primary workitems | Owning team | | `Timebox` | single | primary workitems | Sprint / iteration | | `Parent` | single | see below | Task/Test → owning primary workitem; Story/Defect/TestSet → owning Theme; Theme → parent Theme | | `Super` | single | primary workitems, Epics | The owning Epic; Epics nest via `Super`/`Subs` | | `Owners` | multi | work items | Assigned members | | `Children` | multi | primary workitems | Owned Tasks and Tests | | `Dependencies` | multi | primary workitems | Upstream items this one depends on | | `Dependants` | multi | primary workitems | Downstream items that depend on this one | | `AffectedByDefects` | multi | primary workitems | Defects impacting this item | There is no `BlockedBy` attribute — dependency questions are asked through `Dependencies` and `Dependants`. As always, when a name surprises you, [check `meta.v1`](/apis/meta-v1). ## Traversing the Graph Dots traverse to any depth, and each path arrives as its own flattened key: ```json { "from": "Task", "where": { "ID": "Task:1469" }, "select": ["Name", "Parent.Number", "Parent.Scope.Name"] } ``` ```json [[{ "_oid": "Task:1469", "Name": "Design Login Page", "Parent.Number": "S-01001", "Parent.Scope.Name": "Release 1.0" }]] ``` Filtering traverses the same way — and works on the relation itself, where you should compare **OIDs**, not names: ```json { "from": "Story", "where": { "Scope": "Scope:1308" }, "filter": ["Owners='Member:20'", "Status.Name='Done'"], "select": ["Number", "Name"], "page": { "size": 50, "start": 0 } } ``` Paths can also carry inline filters in brackets (`Owners[AssetState!='Dead'].Name`) — see [attribute paths](/reference/query-grammar/attribute-paths). ### Every Relation Has a Reverse Relation definitions come in reciprocal pairs, discoverable in `meta.v1` (`ReciprocalRelation`): `Story.Owners` ↔ `Member.OwnedWorkitems`, `Task.Parent` ↔ `Story.Children`. So "this story's owners" and "this member's stories" are both one hop — pick whichever direction starts from the asset you have. ## Derived and Rollup Relations Many convenience relations are **read-only projections** maintained by the system — you write the underlying relation and read the convenience: - **`Task.Scope` is read-only** — a task lives in its parent's project. Move the story and its tasks follow; you cannot scope a task separately. - **`Scope.ParentMeAndUp` / `Scope.ChildrenMeAndDown`** — a scope plus all its ancestors / descendants. These power the single most useful filter idiom in Agility, *"in this project or any project beneath it"*: ```json { "from": "Story", "filter": ["Scope.ParentMeAndUp='Scope:1308'"], "select": ["Number", "Scope.Name"], "page": { "size": 50, "start": 0 } } ``` It reads backwards until it clicks: *the target scope is among the ancestors-or-self of the story's scope* — i.e. the story is in the target's subtree. - **`Epic.Subs`**, **`Scope.Workitems`**, **`Team.Workitems`** — read-only rollups of the corresponding forward relations. ## Modifying Relations Set single relations and add/remove on multi relations, on either write surface. The [Bulk API relation-updates page](/apis/asset-bulk/relation-updates) is the full reference; `rest-1.v1` equivalents are in [update examples](/apis/rest-1/update-examples). ```json // api/asset — set a single relation, adjust a multi relation { "from": "Story:1207", "update": { "Timebox": "Timebox:1010", "Owners": { "add": ["Member:20"], "remove": ["Member:1035"] } } } ``` ```xml ``` ## Deletes Cascade Down the Containment Tree Deleting an asset soft-deletes what it contains — verified live: deleting a Story deletes its child Tasks. The children are recoverable exactly like their parent, but note the two-flag requirement to even *see* them: the `?deleted=1` URL parameter **and** `IsDeleted: true` in the query — ```json // POST /query.v1?deleted=1 { "from": "Task", "where": { "ID": "Task:3887", "IsDeleted": true }, "select": ["Name", "IsDeleted", "AssetState"] } ``` Either flag alone returns nothing. See [troubleshooting](/troubleshooting) for this and related traps. ## The Hierarchies at a Glance The domain has **several distinct hierarchies**, and primary workitems (Story, Defect, TestSet) participate in two of them at once — through two different relation pairs: ```text Scope tree Portfolio (Super / Subs) Classification & breakdown (Parent / Children) Scope Epic Theme └─ Scope ├─ Epic (nesting) ├─ Theme (nesting) └─ … └─ Story/Defect/TestSet └─ Story/Defect/TestSet └─ Task, Test ``` - **`Super` / `Subs`** is the portfolio hierarchy: an Epic can be the `Super` of another Epic or of a primary workitem; `Epic.Subs` (typed `Workitem`, read-only) therefore holds a mix of Epics and primary workitems. - **`Parent` / `Children` means different things by level.** On a Task or Test, `Parent` is the owning primary workitem (breakdown). On a primary workitem, `Parent` is a **Theme** (classification into backlog groups) — and Themes nest via their own `Parent`. `Theme.Children` (typed `Workitem`, read-only) holds Themes and primary workitems alike. So a Story can simultaneously have a `Super` (its Epic), a `Parent` (its Theme), and `Children` (its Tasks and Tests) — three hierarchies, one asset. When traversing, downcast mixed collections (`Subs:Epic`, `Children:Task`) to keep types straight. Two scope rules ride along with these hierarchies: - **The portfolio hierarchy crosses project boundaries**: an Epic and its `Subs` can live in different Scopes. - **Breakdown does not**: a Task or Test is always in its parent's Scope (`Task.Scope` is read-only). The two types differ in who may be that parent: `Task.Parent` is typed `PrimaryWorkitem`, so parenting a Task to an Epic is rejected with `400 Wrong OID AssetType` — while `Test.Parent` is typed `Workitem`, and parenting a Test to an Epic **works** (both verified live). Newer portfolio-alignment types — `Okr` (with its `KeyResult` subtype) and `StrategicTheme` — carry their own linkage vocabulary instead of `Parent`/`Super` pairs: `Okr.CascadedTo`, `Okr.AlignedWorkitems`, `StrategicTheme.AlignedOkrs`, `StrategicTheme.Epics`. Explore them through `meta.v1` if your instance uses OKRs. For time-sliced hierarchy reporting, see [reproducing reported numbers](/guides/reproducing-reported-numbers). ## Patterns That Come up Constantly **Everything assigned to a member, across all work-item types** — query the abstract type and filter by OID: ```json { "from": "Workitem", "filter": ["Owners='Member:20'", "AssetState!='Dead'"], "select": ["Number", "Name", "Status.Name"], "sort": ["-ChangeDateUTC"], "page": { "size": 50, "start": 0 } } ``` **A project and everything beneath it** — the `Scope.ParentMeAndUp` idiom above. **Only the topmost Epic above a Story** — `SuperAndUp` walks the full Epic chain; a bracket filter for "has no `Super` of its own" keeps just the root: ```json { "from": "Story", "where": { "Scope": "Scope:1308" }, "select": ["Number", "SuperAndUp[-Super].Number", "SuperAndUp[-Super].Name"], "page": { "size": 50, "start": 0 } } ``` **Story with its epic and its tasks in one request** — dotted paths upward, a nested subquery downward: ```json { "from": "Story", "where": { "ID": "Story:1207" }, "select": [ "Number", "Name", "Super.Number", "Super.Name", { "from": "Children:Task", "select": ["Number", "Name", "Status.Name"] } ] } ``` ## Performance Notes Every relation you project or filter through costs a join, and multi-value relations multiply rows. Project the minimum, filter on OIDs, and page — [query size and performance](/guides/query-size-and-performance) covers the discipline in full. ## Related Topics - [Attributes](/concepts/attributes) — the attribute side of the model - [Attribute paths](/reference/query-grammar/attribute-paths) — dots, downcasts, bracket filters - [Bulk API relation updates](/apis/asset-bulk/relation-updates) — add/remove semantics in full - [Moments and history](/concepts/moments-and-history) — relations through time --- # Operations in Agility - URL: https://docs.digital.ai/agility-api/concepts/operations - Markdown: https://docs.digital.ai/agility-api/concepts/operations.md - Source: concepts/operations.md - Description: Understanding operations - the named actions that change asset lifecycle state in Digital.ai Agility - Tags: concepts, operations, state-management Operations are named actions defined per [asset type](/concepts/assets) that change an asset's lifecycle state or trigger built-in behaviour — closing, deleting, copying, converting. They are distinct from attribute updates: an operation is _invoked_, not _set_. ## Discovering Operations Each type's operations are listed in its Meta API description. `Story` on a 26.2 instance: ```text Inactivate, Reactivate, Delete, Undelete, Copy, ShallowCopy, DeepCopy, QuickClose, QuickSignup, Split, Breakdown, ConvertToDefect, MakeTemplate ``` The set varies by type — check `GET /meta.v1/` for yours. Naming varies too: most types close via `Inactivate`, while `Timebox` has `Close`. ## Invoking an Operation On `rest-1.v1`, POST to the asset with `?op=` and an **empty body**: ```bash curl -X POST -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \ "${AGILITY_BASE_URL}/rest-1.v1/Data/Story/3884?op=Inactivate" ``` The response is the asset, including its OID with the new moment (`Story:3884:6277`). On `api/asset`, use an [execute command](/apis/asset-bulk/operations) — which can run one operation across many assets in a single request. ## The Lifecycle Quartet The four operations nearly every type has, and what they actually do: | Operation | Effect | After it runs | | ------------ | ------------------------ | --------------------------------------------------------------------------------------------------- | | `Inactivate` | Closes the asset | `AssetState: Closed`, `IsDeleted: False` | | `Reactivate` | Reopens a closed asset | `AssetState: Active` | | `Delete` | Soft-deletes the asset | Hidden from queries unless you [ask for deleted assets](/troubleshooting); recoverable until purged | | `Undelete` | Restores a deleted asset | Back to its prior state | :::warning Inactivate is not Delete A persistent confusion, worth stating plainly: **`Inactivate` closes; it does not touch `IsDeleted`.** Closed assets remain fully queryable. `Delete` is the soft delete — and even deleted assets are still stored and recoverable until an administrator purges them. ::: Two interactions between the pairs: - **A Closed asset cannot be deleted** — `Delete` on it fails with `Violation'AssetCannotDeleteClosed`. `Reactivate` first, then `Delete`. - **A Closed asset accepts only backfill** — writing an attribute that had no value succeeds without reopening (by design, so missing values can be pushed after close), but changing an existing value fails with `Violation'ClosedAssetCannotEditExistingValues`. The whole lifecycle in one picture: ```mermaid stateDiagram-v2 [*] --> Active : create Active --> Closed : Inactivate Closed --> Active : Reactivate Active --> Deleted : Delete (soft) Deleted --> Active : Undelete note right of Closed Delete refused (AssetCannotDeleteClosed) Writes are backfill-only end note ``` `Undelete` has a quirk when invoked through the Bulk API: the update/execute command must include `IsDeleted: true` to find its target — see [updating assets](/apis/asset-bulk/updating-assets). ## The Copy Family `Copy`, `ShallowCopy` and `DeepCopy` create new assets from an existing one. On `rest-1.v1` the response hands you the new asset: ```text POST /rest-1.v1/Data/Story/3884?op=Copy → ← the copy's OID ``` If your integration needs the copy's OID, invoke the operation through `rest-1.v1` — the Bulk API's execute command does not return the OID of an asset an operation generates. ## The Convert Family `ConvertToDefect` (on `Story`) and `ConvertToStory` (on `Defect`) migrate a work item across types. The operation creates a **new** asset of the destination type: attribute values the two types share are copied, child tasks and tests move, effort records move, and attachments and links are copied. The source asset is then renamed ` (Converted To D-01234)` and soft-deleted. The conversion is transactional — if any step fails, nothing changes. Required attributes are enforced when the new asset is saved. An attribute that is required on the destination type but ends up with no value — typically one that exists only on the destination type, such as a required custom field — fails the whole conversion with `Violation'Required'AttributeDefinition''`, and the source work item is left untouched. The conversion operation itself accepts argument values for destination attributes — that is how the product UI satisfies destination-only required fields when it converts. API delivery of those arguments is limited: the body of a `rest-1.v1` `?op=` POST is ignored, and through the Bulk API's execute command `args` map, only **text-type** argument values (`Text`, `LongText`) reach the conversion — keys are full attribute tokens on the destination type, e.g. `"Defect.FoundBy"`. Numeric, relation, and multivalue argument values fail, and the conversion fails with them. For a required destination field that isn't text: populate it on the source first if the types share it, and if it exists only on the destination type, complete the conversion in the UI. As with the copy family, the Bulk API's execute command does not return the new asset's OID — `rest-1.v1` does. To locate the asset a Bulk API conversion created, query the destination type: attribute values, including `Name`, are copied to it. ## Status Changes Are Not Operations Moving a work item through its workflow — _Future_ → _In Progress_ → _Done_ — is an ordinary **attribute update** to `Status`, which is a relation to a list value. Discover the values by querying the list type: ```json { "from": "StoryStatus", "select": ["Name", "Order", "RollupState"], "sort": ["Order"], "page": { "size": 50, "start": 0 } } ``` (Status list values carry `Name`, `Order`, `Description` and `RollupState` — there is no `IsClosed` flag. `RollupState` is what reporting uses to treat a status as effectively closed; see [reproducing reported numbers](/guides/reproducing-reported-numbers).) Then set the status like any relation, on either write surface: ```xml ``` ```json // rest-1.v1 also accepts JSON, with an Attributes wrapper — verified live { "Attributes": { "Status": { "value": "StoryStatus:134", "act": "set" } } } ``` ```json // api/asset update command { "from": "Story:3884", "update": { "Status": "StoryStatus:134" } } ``` The API does not walk a transition graph: `Status` can be set directly to any active list value. Board-style transition rules are a UI concern. Use OIDs for the values — status _names_ are localized and instance-editable. ## History of Operations Every operation writes a new moment. To see who did what and when, read the asset's history — see [Moments and history](/concepts/moments-and-history) and the [`/rest-1.v1/Hist` namespace](/apis/rest-1). ## Permissions Whether you may invoke an operation is governed by your member's role in the asset's scope — see [Roles and permissions](/reference/roles-and-permissions). A denied operation fails; handle it like any other authorization error. ## Code Example ```typescript class OperationClient { constructor( private baseUrl: string, private headers: HeadersInit ) {} /** Invoke a lifecycle operation. Returns the response asset's OID. */ async execute(oid: string, operation: string): Promise { const [type, id] = oid.split(':'); const response = await fetch( `${this.baseUrl}/rest-1.v1/Data/${type}/${id}?op=${operation}`, { method: 'POST', headers: this.headers } ); if (!response.ok) throw new Error(`${operation} failed: ${response.status}`); const text = await response.text(); return /id="([^"]+)"/.exec(text)?.[1] ?? ''; } } // Close a story; copy one and get the copy's OID const client = new OperationClient(baseUrl, headers); await client.execute('Story:3884', 'Inactivate'); const copyOid = await client.execute('Story:3884', 'Copy'); ``` ## Related Topics - [Assets](/concepts/assets) — AssetState vs Status - [Bulk API operations](/apis/asset-bulk/operations) — execute across many assets - [rest-1 operations](/apis/rest-1/operations) — the `?op=` protocol in detail - [Workflows and status](/concepts/workflows) — the workflow side of the model --- # Scopes and Projects - URL: https://docs.digital.ai/agility-api/concepts/scope-projects - Markdown: https://docs.digital.ai/agility-api/concepts/scope-projects.md - Source: concepts/scope-projects.md - Description: Understanding Scope - the asset behind Projects - its hierarchy, lifecycle and role in queries and permissions - Tags: concepts, scope, projects `Scope` is the asset type behind what the UI calls a **Project**. Scopes form a tree, every work item belongs to exactly one, and they are the boundary for permissions and the primary dimension for reporting. ## Key Attributes | Attribute | Type | Notes | | ------------------------------------ | ----------------- | ------------------------------------------------------------ | | `Name` | Text | **Required** | | `Parent` | Relation | **Required** — the containing scope | | `BeginDate` | Date | **Required** | | `Scheme` | Relation | **Required** — controls available list values and roles; shown in the UI as **Project Workspace** | | `EndDate` | Date | Optional — and [exclusive](/apis/conventions/dates): the stored date is the day *after* the last day | | `Description` | LongText | | | `Status` | Relation | Descriptive list value — not the lifecycle (see below) | | `Owner` | Relation | A Member | | `Members` | Relation (multi) | Members with access — see [roles and permissions](/reference/roles-and-permissions) | | `Schedule` | Relation | Where the scope's Timeboxes come from | | `Children`, `Workitems` | Relation (multi, read-only) | System-maintained rollups | | `ParentMeAndUp`, `ChildrenMeAndDown` | Relation (multi, read-only) | Self-plus-ancestors / self-plus-descendants — the subtree workhorses | ## The Tree and Its Root Every instance has a single root scope — **`Scope:0`, "System (All Projects)"**, whose `Parent` is null. Everything else hangs beneath it: ```text Scope:0 System (All Projects) └─ Scope:1308 Release 1.0 └─ … ``` ### Walk the Tree Without Recursion The rollup relations answer ancestry questions in one query — never write a recursive loop over `Parent`: ```json { "from": "Scope", "where": { "ID": "Scope:1308" }, "select": ["Name", "Parent.Name", "ParentMeAndUp.Name", "ChildrenMeAndDown.Name"] } ``` And the single most useful filter in Agility — *work in this project or any project beneath it*: ```json { "from": "Story", "filter": ["Scope.ParentMeAndUp='Scope:1308'"], "select": ["Number", "Name", "Scope.Name"], "page": { "size": 50, "start": 0 } } ``` See [Relations](/concepts/relations#derived-and-rollup-relations) for why that filter reads backwards and still means "subtree". ## Creating a Scope Four attributes are required: `Name`, `Parent`, `BeginDate` and `Scheme`. The simplest correct pattern copies the parent's scheme: ```json [ { "AssetType": "Scope", "Name": "API Gateway Project", "Parent": "Scope:1308", "BeginDate": "2026-09-01", "Scheme": "Scheme:1001" } ] ``` (Read the parent's `Scheme` first: `{"from": "Scope", "where": {"ID": "Scope:1308"}, "select": ["Scheme"]}`.) ## Lifecycle - **Closing a project is an operation, not a status change**: `?op=Inactivate` sets `AssetState` to Closed (the UI calls closed scopes *Inactive*). `Reactivate` reopens. The `Status` attribute is a descriptive list value and does not drive the lifecycle. - **`EndDate` is exclusive** — a project that ends December 31 stores January 1. See [date conventions](/apis/conventions/dates) before doing arithmetic. - `Delete` / `Undelete` behave as everywhere: [soft delete, recoverable](/concepts/operations#the-lifecycle-quartet). - Two scope-specific operations worth knowing exist: `AssignMemberWithRole` (grant a member a role on the scope — the API-friendly way to provision access; see [roles and permissions](/reference/roles-and-permissions)) and `ConvertToTeam`. ## Scope Vs Timebox Both group work, on different axes: - **Scope answers "where does this work live?"** — organizational, hierarchical, permission-bearing, long-lived. - **Timebox answers "when is it planned?"** — sequential sprints/iterations that work moves through. They meet through `Schedule`: a scope points at a Schedule, and the Timeboxes on that schedule are the sprints available to work in that scope. Scopes sharing a Schedule share sprint cadence. ## Querying by Scope ```json // exactly this project { "from": "Story", "where": { "Scope": "Scope:1308" }, "select": ["Number", "Name"], "page": { "size": 50, "start": 0 } } ``` ```json // this project and everything beneath it { "from": "Story", "filter": ["Scope.ParentMeAndUp='Scope:1308'"], "select": ["Number", "Name"], "page": { "size": 50, "start": 0 } } ``` ```json // across all projects, in one paged sweep — filter later, not never { "from": "PrimaryWorkitem", "filter": ["AssetState!='Dead'"], "select": ["Number", "Name", "Scope.Name"], "page": { "size": 200, "start": 0 } } ``` ## Moving Work Between Scopes Set the work item's `Scope`; its Tasks and Tests follow automatically — `Task.Scope` is read-only and derives from the parent: ```json { "from": "Story:1207", "update": { "Scope": "Scope:1400" } } ``` ## Permissions A member's access is granted **per scope**, through roles — the scope tree is the permission tree. The details (Rights vs Privileges, the thirteen roles, why display names lie) live in [Roles and permissions](/reference/roles-and-permissions); the read-only view of assignments is [api/ProjectRoles](/apis/project-roles). ## Related Topics - [Relations](/concepts/relations) — rollup relations and the subtree idiom - [Date conventions](/apis/conventions/dates) — exclusive EndDate - [Roles and permissions](/reference/roles-and-permissions) - [Query size and performance](/guides/query-size-and-performance) --- # Workflows and Status - URL: https://docs.digital.ai/agility-api/concepts/workflows - Markdown: https://docs.digital.ai/agility-api/concepts/workflows.md - Source: concepts/workflows.md - Description: How status lists, RollupState and asset lifecycle interact in Digital.ai Agility - and what the API does and does not enforce - Tags: concepts, workflows, status In Agility's API, a "workflow" is a **list of status values** that work items move through — not a state machine. The API places no restrictions on which status you set next; boards and team process impose order, the server does not. What the server *does* maintain is each status's **rollup category**, which quietly drives every report. ## Which Types Use Which Status List Status lists are shared across related types — verified from the schema: | Work item type | Status list type | | ------------------------- | ---------------- | | Story, Defect, TestSet | `StoryStatus` — one shared list for all primary workitems | | Task | `TaskStatus` | | Test | `TestStatus` | | Epic | `EpicStatus` | There is no `DefectStatus`: a Defect's *Status* dropdown draws from the same `StoryStatus` list as Stories. Rename or reorder a value and every primary workitem type sees the change. ## Anatomy of a Status Value Status values are assets. In the schema their types descend from an abstract asset type named `List` (`StoryStatus` → `Status` → `List`) — a name that reads backwards at first: each individual **value** is-a `List`, so the type really means *list entry*. The list as a whole has no asset of its own — "the StoryStatus list" is simply every asset of type `StoryStatus`, optionally narrowed by a [team's process or a scheme](#per-project-and-per-team-availability). When [`meta.v1`](/apis/meta-v1) shows a relation whose target descends from `List`, read it as "picks one entry from a value list". The attributes that matter: | Attribute | Type | Notes | | ------------- | -------- | ---------------------------------------------------------------------- | | `Name` | Text | Instance data — editable, so **code should match on OID, not name** | | `Order` | Rank | Opaque ordering token: **sort by it, never read it** | | `Description` | LongText | | | `RollupState` | State | The status's lifecycle category: `Future`, `Active` or `Closed` | Discover a list, in display order: ```json { "from": "StoryStatus", "select": ["Name", "RollupState"], "sort": ["Order"], "page": { "size": 50, "start": 0 } } ``` ## RollupState `RollupState` exists on **`StoryStatus` and `EpicStatus`** — Task and Test statuses have no rollup, so effective-state reasoning applies to primary workitems and Epics, not to their breakdown items. Where it exists, every status value carries one — and it does not always match what the name suggests. A live 26.2 sample: | Status name | RollupState | | -------------- | ----------- | | Future | Future | | In Progress | Active | | Testing | Active | | **In UAT** | **Closed** | | Done | Closed | | Accepted | Closed | That *In UAT → Closed* row is the kind of configuration that makes an integration's numbers disagree with the product's: reporting treats an Active asset as effectively closed once its status rolls up as Closed. If your counts don't match a dashboard, this is the first place to look — see [reproducing reported numbers](/guides/reproducing-reported-numbers). RollupState also drives the **`DoneDate` / `DoneDateUTC`** synthetic attributes (read-only): they are stamped the moment a workitem's *effective* state moves to Closed — verified live: setting Status to a Closed-rolling value populated `DoneDateUTC` immediately, while `AssetState` remained Active. If you report cycle time, this is your completion timestamp — not the moment someone ran `Inactivate`. There is no `StartedDate` — but you can reconstruct it (and who caused it) with a **historical projection** over the asset's history, filtering for the moment the effective state moved from Future to Active. It works, verified live, but it is costly — use it for targeted assets, not instance-wide sweeps: ```json { "from": "Story", "where": { "ID": "Story:1207" }, "select": [ "Name", "History[Prior.EffectiveAssetState='Future';EffectiveAssetState='Active'].ChangeDateUTC", "History[Prior.EffectiveAssetState='Future';EffectiveAssetState='Active'].ChangedBy.Name" ] } ``` (The semicolon inside the bracket filter is AND; `History` and `Prior` reach across [moments](/concepts/moments-and-history).) **It is often less costly to skip the filter** and pull the workitem's whole Status/AssetState history in one go, finding the transitions client-side. `History` works as a subquery, returning one object per moment: ```json { "from": "Story", "where": { "ID": "Story:1207" }, "select": [ "Name", { "from": "History", "select": ["ChangeDateUTC", "Status.Name", "AssetState"] } ] } ``` ```json "History": [ { "_oid": "Story:1207:1482", "ChangeDateUTC": "2026-05-25T…", "Status.Name": "Future", "AssetState": "Active" }, { "_oid": "Story:1207:1605", "ChangeDateUTC": "2026-05-26T…", "Status.Name": "Future", "AssetState": "Active" } ] ``` Each entry's `_oid` carries the moment (`Story:1207:1482`) — see [Moments and history](/concepts/moments-and-history). `RollupState` is also directly filterable, which gives you process-independent queries that survive status renames: ```json { "from": "Story", "filter": ["Status.RollupState='Active'"], "select": ["Number", "Name", "Status.Name"], "page": { "size": 50, "start": 0 } } ``` ## Changing Status A status change is an ordinary attribute update — see [Operations](/concepts/operations#status-changes-are-not-operations) for the payload shapes on each write surface. Three facts to build on: 1. **No transition order** is enforced — Future to Accepted in one write is accepted. But **value membership is**: a team using a Team Process has its own set of `StoryStatus` values its primary workitems may be in, and that set is enforced on status change — verified live. Writing a status outside the team's process fails with `Violation'PrimaryWorkitemCannotHaveInvalidStatus`. (Status values carry a `Team` relation — a team-scoped value belongs to that team's process.) The process itself is queryable, though the attribute names are not guessable: **`Team.HasProcess`** says whether the team enforces one, and **`Team.SelectedValues`** is the allowed set. ```json { "from": "Team", "select": ["Name", "HasProcess", { "from": "SelectedValues", "select": ["Name"] }] } ``` A team with `HasProcess: "False"` has an empty `SelectedValues` and enforces nothing. A status value belongs to a team's process **or** to a project scheme, never both — creating one with both a `Team` and `SelectedInSchemes` fails with `Violation'ListValueCannotBelongToBothSchemeAndTeam`. To ask the same question for one specific workitem — with every narrowing already applied — use [`api/ValidValues`](/apis/valid-values) instead. 2. **Use OIDs.** Status names are instance data and freely editable. 3. **Status is not lifecycle.** Setting a Closed-rolling status does not close the asset; `AssetState` changes only through operations like `Inactivate`. An asset can be `Status` = *Done* and `AssetState` = Active indefinitely — see [Assets](/concepts/assets#asset-state-and-status--two-different-things). ## Per-Project and per-Team Availability Two mechanisms narrow which status values actually apply to a given work item — discover per context rather than assuming one universal set: - **Scheme** — shown in the UI as the **Project Workspace** — controls which values a *project* offers (status values carry a `SelectedInSchemes` relation). - **Team Process** — a team can have its own set of `StoryStatus` values for its primary workitems, enforced on status change. Two related concepts ride along but are **advisory only** — the UI calls them out, the API does not enforce them: the team's **WIP cycle** (the subset of its statuses whose `RollupState` is Active) and its **WIP limit** (a cap on workitems in progress). Epics are not subject to team processes. ## What Agility Offers Instead of Enforcement The product covers the workflow-enforcement need with softer mechanisms an integration should know about: - **Important fields (soft requirements).** A Scheme can flag attributes as important (`Scheme.ImportantFields`): the UI calls them out, and with `Scheme.IsRequiredAtClose` they become mandatory **when the asset is closed** — so a close can fail validation even though ordinary updates never did. The [`api/canbeimportant`](/apis/field-visibility/can-be-important) endpoint reports which attributes are eligible. - **Closed assets accept backfill — and only backfill.** A Closed asset takes an attribute update without being reopened **only if the attribute had no value**; changing an existing value fails with `Violation'ClosedAssetCannotEditExistingValues` (both verified live — including that a value backfilled after close immediately becomes uneditable itself). The design intent is pushing missing values, not editing history. - **Quick Transitions** — follow-up actions configured in the UI that run when an asset reaches a new Status or AssetState. They are product configuration, not part of the API surface. ## Useful Queries ```json // everything still genuinely open, by rollup rather than name { "from": "PrimaryWorkitem", "filter": ["Status.RollupState!='Closed'", "AssetState='Active'"], "select": ["Number", "Name", "Status.Name"], "page": { "size": 100, "start": 0 } } ``` ```json // stale in-flight work: active rollup, untouched for 30 days { "from": "Story", "filter": ["Status.RollupState='Active'", "ChangeDateUTC<'2026-07-27'"], "select": ["Number", "Name", "Status.Name", "ChangeDateUTC"], "sort": ["ChangeDateUTC"], "page": { "size": 50, "start": 0 } } ``` ## Related Topics - Product feature: [Team Process management](https://docs.digital.ai/agility/docs/agility/team-process-management) and [What impact does defining Team Process have on my instance?](https://docs.digital.ai/agility/docs/agility/what-impact-does-defining-team-process-have-on-my-instance) — configuring the processes whose enforcement this page describes. - [Assets](/concepts/assets) — AssetState vs Status - [Operations](/concepts/operations) — how lifecycle actually changes - [Reproducing reported numbers](/guides/reproducing-reported-numbers) — RollupState in reporting - [Attribute types](/reference/attribute-types) — Rank and State types --- # Moments & History - URL: https://docs.digital.ai/agility-api/concepts/moments-and-history - Markdown: https://docs.digital.ai/agility-api/concepts/moments-and-history.md - Source: concepts/moments-and-history.md - Description: How Agility journals changes as globally-sequential moments, and how moments drive the Hist namespace, versioned asset addresses and asof queries. - Tags: concepts, moment, history, asof, audit ## Overview Agility does not overwrite data. Changes to assets are journaled, and each change is stamped with a **moment** — a chronologically increasing integer. A few attributes are exceptions: rank order (`Order`) changes, for example, are not journaled at all — an asset's history never shows a rank move. There is no way to discover through the API which attributes are tracked. A moment is the unit of time in the Agility data model. It is what makes the `Hist` namespace, versioned asset addresses and `asof` queries possible, and it is the single concept you must understand before you can read history correctly. ## Moments Are Global, Not per-Asset This is the detail that trips up most integrations. Moment numbers are **sequential for the entire Agility instance**, not for an individual asset. Any change to any asset, by any member, consumes the next moment number in the system. Consider three changes made in this order: | Order | Change | Moment | | ----- | --------------------------------- | ------ | | 1 | A Story is created | 203 | | 2 | That Story's `ToDo` is updated | 204 | | 3 | An unrelated Member's `Name` is updated | 205 | The Story now has two entries in its own history — moment 203 and moment 204 — and the next change anywhere in the system will be moment 206. :::warning[Do not assume a contiguous sequence] Because moments are handed out globally, an individual asset's moments are almost always **sparse**. A Story might have moments 203, 204, 818, 874 and 903. The gaps are other people's changes to other assets. Never compute "the previous version" by subtracting 1 from a moment, and never treat the count of moments between two versions as a measure of elapsed time or of how much changed. To move through an asset's versions, read its history and use the moments it actually reports. ::: ## Addressing a Version An asset is identified by its asset type and integer ID: ```text Member:20 ``` A *past version* of an asset is identified by asset type, ID **and** moment: ```text Member:20:563 ``` That reads as "the Member asset with ID 20, as it was at moment 563". The same three parts appear in the URL of a versioned asset, in the `Moment` path segment: ```text /VersionOne/rest-1.v1/Data/Member/20/563 ``` Omit the moment and you address the asset as it is **now**: ```text /VersionOne/rest-1.v1/Data/Member/20 ``` Relations follow the same rule. A relation attribute on a past version refers to the *past version* of its target asset, so a historical query gives you an internally consistent snapshot rather than old attributes stitched onto current related assets. ## Reading History: The `Hist` Namespace `rest-1.v1` exposes two query namespaces, and they differ only in whether you get one record or all of them: | Namespace | Returns | Root element | | --------- | ---------------------------------------------------------------- | ------------ | | `Data` | The current version of each matching asset | `Assets` | | `Hist` | Every historical version of each matching asset | `History` | So this returns one Member: ```text GET /VersionOne/rest-1.v1/Data/Member/20 ``` and this returns every version that Member has ever had: ```text GET /VersionOne/rest-1.v1/Hist/Member/20 ``` Each `Asset` element in a `History` response carries an `href` that includes the moment, giving you a direct, permanent address for that exact version. History works at attribute granularity too. Asking for the history of a single attribute returns one record per change to *that* attribute, each with an `href` pointing at the attribute and an `of` pointing at the owning asset version: ```text GET /VersionOne/rest-1.v1/Hist/Member/20/Email ``` All of the ordinary query parameters — attribute selection, filtering, sorting, paging and text search — work against `Hist` in the same way they do against `Data`, subject to the same rule that list-oriented parameters only take effect on list-shaped requests. See the [query grammar reference](/reference/query-grammar) for the applicability matrix. :::tip[`ChangeDate` is maintained by history] `ChangeDate` is populated by the history subsystem and records the wall-clock time at which a version was written. Select it whenever you need to correlate moments with real time, because a moment number on its own tells you ordering but not when. ::: ## Reading History as a Relation The [`Hist` namespace](#reading-history-the-hist-namespace) is not the only way to reach history, and often not the most convenient. Every asset type also exposes history through **ordinary relations**, which means you can traverse it with the [attribute-path syntax](/reference/query-grammar/attribute-paths) from any surface — including [`query.v1`](/apis/query-v1) and the [Bulk API](/apis/asset-bulk), neither of which has a `Hist` namespace of its own. | Attribute | Type | From | Gives you | | --- | --- | --- | --- | | `History` | Relation, **multi-value** | a current asset | every version of it | | `Now` | Relation | a version | the current asset — the reciprocal of `History` | | `Prior` | Relation | a version | the version immediately before it | | `ChangedBy` | Relation | a version | the Member who made the change | | `ChangeDate` | Date | a version | when the change happened | | `ChangeComment` | Text | a version | the comment supplied with the change | | `Moment` | Opaque | a version | the moment itself | All seven are read-only **as history attributes** — you read them, you do not select them to write. `ChangeComment` is the exception worth knowing: you cannot set it by selecting it, but you *can* supply it at write time — `?comment=` on [`rest-1.v1`](/apis/rest-1#recording-why-a-change-was-made) or a `ChangeComment` key on [`api/asset`](/apis/asset-bulk/updating-assets#recording-a-change-comment) — and it then appears here. `Prior` is what makes before-and-after comparison possible in a single query: within one history row, `Description` is the value **at** that moment and `Prior.Description` is the value **immediately before** it. `Now.Description` reaches past both to the current value. :::tip `Now.` is the `query.v1` equivalent of the `#` operator On `rest-1.v1` you write `Parent#.Name` to mean "the *current* value, not the value at this moment" — and you must [percent-encode the `#`](/reference/query-grammar/attribute-paths) or lose it. `Now.` does the same job as a plain relation traversal, so it needs no escaping and works anywhere attribute paths work. ::: ### Finding What Changed Query an asset's history as a nested result set, selecting both the value at each moment and the value before it: ```json { "from": "Defect", "select": [ { "from": "History", "select": ["Now.Name", "Prior.Description", "Description", "ChangedBy.Name"] } ], "filter": "Number=$Numbers", "with": { "$Numbers": ["D-14519", "D-16384"] }, "page": { "start": 0, "size": 2 } } ``` Each row where `Prior.Description` differs from `Description` is a change to that attribute, attributed to `ChangedBy.Name`. ### Restoring a Previous Value There is no "revert" operation. Reading history and writing the old value back is the supported approach: find the moment where the attribute changed, take `Prior.`, and update the asset with it. :::danger Strip the moment before you write OID tokens in history results carry a **moment suffix** — `Defect:1234:5678`, not `Defect:1234`. That third segment identifies a *version*, and passing it as the `from` of an update targets a historical row rather than the live asset. Take the first two segments only: ```json { "from": "Defect:1234", "update": { "Description": "" } } ``` See [addressing a version](#addressing-a-version). ::: ## Point-in-Time Queries: `asof` Moments are precise but opaque — you rarely know the moment number you want. The `asof` query parameter lets you ask the question in terms of wall-clock time instead: ```text GET /VersionOne/rest-1.v1/Hist/Story/1364?asof=2005-08-03T17:30:00.00&sel=Name,ToDo,ChangeDate ``` Two rules govern the result: 1. **`asof` returns the last historical record on or before the given time.** It does not require a change to have happened at that instant. In the example above, the Story was last changed at 13:09:05.983 on 2005-08-03; that is the version returned for an `asof` of 17:30, because nothing changed in between. 2. **`asof` overrides an explicit moment.** If you supply both a `Moment` path segment and an `asof` parameter, the moment is ignored. Use one or the other, not both. `asof` applies to single-attribute, single-asset and multi-asset historical queries alike, so you can reconstruct a whole project as it stood on a given day. :::note[What `asof` accepts] `asof` takes either an integer **moment** or a datetime in invariant culture, for example `2005-08-03T17:30:00.00` — the same format `ChangeDate` returns. The full syntax of `asof` and of the other query string parameters is covered in the [query grammar reference](/reference/query-grammar). ::: ## Practical Consequences - **Audit trails come for free.** You do not need to build change tracking on top of Agility; ask `Hist` for the asset and select `ChangeDate` alongside the attributes you care about. - **Trend and burndown data is a series of `asof` queries.** To chart remaining effort over a sprint, issue one `asof` query per point on the x-axis rather than trying to reduce the raw history yourself. - **Cache versioned URLs, not current ones.** A URL containing a moment is immutable and safe to cache indefinitely. A URL without one is not. - **Moments are not timestamps.** Two changes made in the same second get different moments; moments from different Agility instances are unrelated and must never be compared. - **History is voluminous.** A `Hist` query with no filter returns every version of every matching asset. Page it, and select only the attributes you need. ## Related Topics - [Information model](/apis/rest-1/information-model) — the authoritative definition of asset type, attribute definition, asset, attribute and moment. - [Query examples](/apis/rest-1/query-examples) — worked `Hist` and `asof` queries with real response payloads. - [REST API (rest-1.v1)](/apis/rest-1) — the endpoint surface that exposes all of this. - [Query grammar](/reference/query-grammar) — the syntax of `asof`, `sel`, `where`, `sort` and `page`. --- # API Conventions - URL: https://docs.digital.ai/agility-api/apis/conventions - Markdown: https://docs.digital.ai/agility-api/apis/conventions.md - Source: apis/conventions/index.md - Description: Base URLs, HTTP headers, error shapes and terminology shared by every Agility API. - Tags: api, conventions Every Agility API shares a common foundation. Rather than repeat it on each endpoint page, it is collected here. ## Overview | Page | What it covers | | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | | [Base URL](/apis/conventions/base-url) | Deriving `https://{host}/{instance}` from the URL you already use. | | [HTTP headers](/apis/conventions/http-headers) | `Authorization`, `Content-Type` and `Accept` — required on every request. | | [Errors](/apis/conventions/errors) | The status codes these APIs return and how to read an error body. | | [Attribute definition syntax](/apis/conventions/attribute-definitions) | Pointer to the normative query grammar reference. | | [Terminology](/apis/conventions/terminology) | Asset, asset type, asset state, attribute definition, Oid. | ## The Short Version Build the base URL from your instance host and instance name, send the headers, and address assets by Oid token: ```bash title="A complete, minimal request" curl "https://www7.v1host.com/V1Instance/rest-1.v1/Data/Member?where=IsSelf='true'&sel=Username" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` If that returns your own username, your base URL and credentials are correct and every other endpoint in this documentation is reachable the same way. ## Conventions Used in This Documentation - *~/* at the start of a path is shorthand for the base URL, `https://{host}/{instance}`. For example, *~/api/webhook* means `https://www7.v1host.com/MyInstance/api/webhook` — for an instance at `https://www7.v1host.com/MyInstance`. Enter your instance URL in the field at the top of the page and every occurrence of the shorthand on this site becomes a real URL for your instance (saved only in your browser's local storage). - **`:id`, `:OidToken`, `:AssetType`** in a URL are path placeholders you substitute. - **`{host}` / `{instance}`** are the two parts of your base URL. - Endpoint names are given exactly as they appear on the wire, version suffixes included: `rest-1.v1`, `query.v1`, `meta.v1`, `loc-2.v1`, `api/asset`, `api/webhook`, `api/tags`, `api/ActivityStream`. ## Related Topics - [Quickstart](/getting-started/quickstart) - [Authentication](/getting-started/authentication) - [Choosing an API](/getting-started/choosing-an-api) - [Query grammar reference](/reference/query-grammar) — the normative query syntax --- # Base URL - URL: https://docs.digital.ai/agility-api/apis/conventions/base-url - Markdown: https://docs.digital.ai/agility-api/apis/conventions/base-url.md - Source: apis/conventions/base-url.md - Description: How to build the base URL that every Agility API request is relative to, from your instance host and instance name. - Tags: api, conventions, base-url Every request to the Agility API is built from a **host** and an **instance**. Together they form the base URL that all endpoint paths are appended to. ## Finding Your Host and Instance Look at the URL you use to reach Agility in a browser. It generally looks like this: ```text https://www7.v1host.com/V1Instance ``` In that URL: | Part | Value | Meaning | | ------------------ | ------------------ | ----------------------------------------------------------------------- | | `www7.v1host.com` | your **host** | The server your instance is hosted on. | | `V1Instance` | your **instance** | The application path segment identifying your specific Agility instance. | So the base URL is: ```text https://{host}/{instance} ``` ### Base URL Placeholder Code examples throughout this site use *~/* as a placeholder for your Agility instance base URL. For example, *~/api/webhook* means `https://{V1Host}/{V1Instance}/api/webhook` — for an instance at `https://www7.v1host.com/MyInstance`. :::tip Enter your base URL in the field at the top of any page and every *~/* on the page becomes your actual URL, so you can copy a request and run it as-is. Your instance URL stays in your browser's local storage and is never sent anywhere. For example: `~/meta.v1/` ::: ## Building an Endpoint URL Append the endpoint path to the base URL: ```text https://www7.v1host.com/V1Instance/api/tags/all https://www7.v1host.com/V1Instance/rest-1.v1/Data/Story https://www7.v1host.com/V1Instance/query.v1 https://www7.v1host.com/V1Instance/meta.v1 ``` :::note On-premises and custom deployments The instance segment is not always present. Some on-premises deployments host Agility at the root of a site (`https://agility.example.com/`) or under a different application path (`https://intranet.example.com/agility`). Use whatever path segment appears before `rest-1.v1`, `query.v1`, `meta.v1` or `api/` in your own browser URL. If the application is at the site root, the base URL is simply `https://{host}`. ::: :::warning Do not drop version suffixes Endpoint names carry version numbers that are part of the path, not decoration: `rest-1.v1`, `query.v1`, `meta.v1` and `loc-2.v1`. The `-2` in `loc-2.v1` in particular is significant — `loc.v1` is a different, older endpoint. ::: ## One URL That Works by Accident `/rest-1.v1/query.v1` answers queries — but it is a malformed URL that only happens to work, not a real route. `query.v1` is its own endpoint at the instance root: ```text https://www7.v1host.com/MyInstance/query.v1 ✓ canonical https://www7.v1host.com/MyInstance/rest-1.v1/query.v1 ✗ works today, not a contract ``` Integrations built on the accidental form have shipped (it appears in real customer configurations), so it is worth recognizing — and worth fixing to the canonical URL when you touch such code. ## Related Topics - [HTTP headers](/apis/conventions/http-headers) — the headers every request needs - [Authentication](/getting-started/authentication) — how to fill in the `Authorization` header - [Quickstart](/getting-started/quickstart) — your first authenticated call --- # HTTP Headers - URL: https://docs.digital.ai/agility-api/apis/conventions/http-headers - Markdown: https://docs.digital.ai/agility-api/apis/conventions/http-headers.md - Source: apis/conventions/http-headers.md - Description: The Authorization, Content-Type and Accept headers required by every Agility API request, and what each one controls. - Tags: api, conventions, headers, json The Agility APIs can speak both XML and JSON. This documentation shows JSON exclusively, which means every request needs to say so explicitly. ## The Three Headers You Always Send ```bash title="Canonical request shape" curl "https://V1Host/V1Instance/api_endpoint_here" \ -H "Authorization: ******" \ -H "Content-Type: application/json" \ -H "Accept: application/json" ``` | Header | Value | Purpose | | --------------- | ---------------------------------- | ----------------------------------------------------------------------------- | | `Authorization` | scheme-dependent — see below | Identifies the caller. Required on every request. | | `Content-Type` | `application/json` | Tells the server what media type the **request body** is in. | | `Accept` | `application/json` | Tells the server what media type to **respond** with. | `Content-Type` only matters when you are sending a body, so it is strictly required on `POST` requests. Sending it on a `GET` is harmless and keeps client code uniform. :::warning Omitting `Accept` gets you XML Several endpoints default to XML when no `Accept` header is supplied. If you are parsing a response and finding angle brackets where you expected braces, check that `Accept: application/json` is actually reaching the server. ::: :::warning `Content-Type` must be exact A misspelled or unrecognized value is rejected outright rather than defaulted. Send `application/json` exactly. ::: ## The `Authorization` Header The value depends on the authentication scheme you are using. See [Authentication](/getting-started/authentication) for the full treatment. In brief: ```bash title="HTTP Basic" # base64 of "username:password" -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" ``` ```bash title="Access token" -H "Authorization: Bearer " ``` :::note Older on-premises instances may expect the bare token Send `Authorization: Bearer `. If an older on-premises instance returns a `401`, try the header with no scheme prefix (`Authorization: `) before assuming the token itself is invalid. See [Authentication](/getting-started/authentication). ::: ## Other Headers Worth Knowing | Header | When it applies | | ------------------ | -------------------------------------------------------------------------------------------- | | `Accept-Encoding` | `gzip` is supported and worth enabling — query responses are frequently large. | | `Accept-Language` | Influences localized values. See [Localization (loc-2.v1)](/apis/loc-2-v1). | ## Related Topics - [Base URL](/apis/conventions/base-url) — what to prepend to the endpoint path - [Errors](/apis/conventions/errors) — what comes back when a header is wrong - [Authentication](/getting-started/authentication) --- # Errors - URL: https://docs.digital.ai/agility-api/apis/conventions/errors - Markdown: https://docs.digital.ai/agility-api/apis/conventions/errors.md - Source: apis/conventions/errors.md - Description: The HTTP status codes the Agility APIs return, what each one means, and how to read the error payload. - Tags: api, conventions, errors, status-codes The Agility APIs signal failure with conventional HTTP status codes and, in most cases, a body that explains what went wrong. ## Status Codes | Code | Message | Meaning | | ----- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Bad Request | The most common error. The request could not be processed. The error message identifies the specific problem, and the payload may carry `Exception` elements with detailed information. | | `401` | Unauthorized | Credentials are missing, malformed, or invalid — including an expired or revoked access token. | | `404` | Not Found | The specified resource does not exist. For asset endpoints this also covers assets the caller cannot see, since visibility is enforced before existence is disclosed. | | `405` | Method Not Allowed | The HTTP method is not valid at this URL. Returned for any method other than `GET` or `POST`, and for a `POST` to a URL that does not support updating. | | `500` | Internal Server Error | An unexpected error occurred while processing the request. The response payload contains `Exception` elements with information that helps diagnose the cause. | :::note `GET` and `POST` only Notice what `405` implies: these APIs are not verb-rich REST. `PUT`, `PATCH` and `DELETE` are generally *not* accepted, and destructive actions are modeled as `POST` to a command-shaped URL instead. [Deleting a webhook subscription](/apis/webhooks/delete) is the clearest example — it is a `POST` to `.../api/webhook/delete/:id`, not a `DELETE`. ::: ## Reading an Error Body A `400` from the JSON endpoints returns a message describing the failure. The bulk `~/api/asset` endpoint is the exception: it can return `200 OK` at the HTTP layer while reporting per-command failures inside the response body, because a single request carries many independent commands. Always inspect the body of a bulk response rather than trusting the status code alone. See [Bulk API (api/asset)](/apis/asset-bulk). ### Query Parameter Errors Malformed `sel`, `where`, `with`, `find`/`findin`, `sort` and `page` parameters produce their own distinct `400` messages, returned in the `Error` / `Message` / `Exception` envelope. Those literal wire strings and the envelope shape are documented once, in the "Query parameter errors" section of the [HTTP status codes reference](/reference/status-codes) — they are not repeated here. ## Violation Tags Are Localizable Many API errors arrive as **tags** — `Violation'AssetCannotDeleteClosed`, `Violation'Authentication'InvalidAccessToken` — rather than prose. Some of these resolve to human-readable messages through the [localization API](/apis/loc-2-v1), verbatim from a live instance: ```json title="GET ~/loc-2.v1?[Violation'AssetCannotDeleteClosed,Violation'PrimaryWorkitemCannotHaveInvalidStatus]" { "Violation'AssetCannotDeleteClosed": "Agility Warning: Attempting to delete a closed item. This change was not saved.", "Violation'PrimaryWorkitemCannotHaveInvalidStatus": "The chosen Status is not valid for the Backlog with the assigned Team" } ``` Not every error string is a resolvable tag, but a blanket "resolve every error through the localizer" strategy is safe: `loc-2.v1` [echoes unmatched tags back unchanged](/apis/loc-2-v1/tag-resolution), so the worst case is the overhead of a request that returns the tag as-is — never a wrong message. Resolved messages also arrive in the caller's language, which is usually what you want to surface to a user. ## Related Topics - [HTTP status codes reference](/reference/status-codes) — expanded treatment with handling guidance - [Query grammar reference](/reference/query-grammar) — the syntax those parameters must satisfy - [Error handling guide](/guides/error-handling) — retry and recovery patterns - [HTTP headers](/apis/conventions/http-headers) — a wrong `Content-Type` is a common source of `400` --- # Attribute Definition Syntax - URL: https://docs.digital.ai/agility-api/apis/conventions/attribute-definitions - Markdown: https://docs.digital.ai/agility-api/apis/conventions/attribute-definitions.md - Source: apis/conventions/attribute-definitions.md - Description: Where to find the normative attribute-path and filter-expression grammar shared by every Agility query surface. - Tags: api, conventions, attributes, query Every Agility query surface — `rest-1.v1`, `query.v1`, `~/api/asset` and webhook event definitions — selects and filters data using the same attribute-path and filter grammar. That grammar is **not documented here**. It is maintained as a single normative reference derived from the parser grammars and the server implementation. See [Query grammar reference](/reference/query-grammar). Go there for: - **Attribute paths** — dot traversal, downcast (`Workitems:Story.Category`), inline path filters, the eight aggregation functions, and the now-only marker. - **Filter expressions** — comparison operators, value lists, boolean composition, and the filter context. - **Sorting and paging** parameters. :::note One source of truth This section deliberately does not restate the grammar. Attribute-path and filter syntax is identical across every API in Agility, so it is documented once. If you find operator or path syntax duplicated in an API page, treat the query grammar reference as authoritative. ::: ## What This Section Does Cover The conventions section covers what is genuinely shared at the transport layer rather than the query layer: - [Base URL](/apis/conventions/base-url) - [HTTP headers](/apis/conventions/http-headers) - [Errors](/apis/conventions/errors) - [Terminology](/apis/conventions/terminology) ## Related Topics - [Query grammar reference](/reference/query-grammar) — the normative syntax - [Attributes](/concepts/attributes) — what attributes and attribute definitions are - [Relations](/concepts/relations) — how assets reference each other - [Query API (query.v1)](/apis/query-v1) --- # Terminology - URL: https://docs.digital.ai/agility-api/apis/conventions/terminology - Markdown: https://docs.digital.ai/agility-api/apis/conventions/terminology.md - Source: apis/conventions/terminology.md - Description: Asset, asset type, asset state, attribute definition and Oid — the five terms the Agility API vocabulary is built from. - Tags: api, conventions, terminology, assets, oid Five terms carry most of the weight in the Agility API. Everything else is built from them. ## Asset A **system asset** is a core business object in the Agility (formerly VersionOne Lifecycle) model. Assets represent the stories and backlog items, defects, sprints and iterations, and the members who use the system. Every read and write in the API is ultimately an operation on assets. See [Assets](/concepts/assets) for the conceptual treatment. ## Asset Type Asset types describe the "classes" of data available. Each asset type carries a collection of attribute definitions, a collection of operations, and metadata such as a default sort order. Asset types form an **inheritance hierarchy**: each type inherits attribute definitions, operations and rules from its parent. Types at the leaves of the hierarchy are **concrete**; types with children are **abstract**. Every asset is an instance of a concrete type. For example, `Story` and `Defect` are concrete asset types. `Workitem` is abstract, and both `Story` and `Defect` ultimately derive from it. Querying `Workitem` therefore returns both. ### System Names Versus UI Names Most asset types share a name with the application UI — a Defect in the UI is a `Defect` asset type. But several of the most common types do not, because the UI name is configurable per methodology while the system name is fixed: | System name | XP | Scrum | AgileUP | DSDM | | ------------ | --------- | ------------- | ----------- | ------------- | | `Scope` | Project | Project | Project | Project | | `ScopeLabel` | Project | Project | Project | Project | | `Story` | Story | Backlog Item | Requirement | Requirement | | `Timebox` | Iteration | Sprint | Iteration | Iteration | | `Theme` | Theme | Feature Group | Use Case | Feature Group | **Always use the system name in API calls.** The UI-facing name is a localized display value; resolve it through [`loc-2.v1`](/apis/loc-2-v1) if you need to render it. ## Asset State Asset states represent the system-known lifecycle stage of an asset. It is a numeric attribute (`AssetState`), and the UI typically shows only assets that are not "dead". Dead is a range, not a single value: every state of `192` or above is dead, and `192` itself is never stored. | State | Name | Meaning | | ----- | ------------------- | ------------------------------------------------------------------ | | `0` | Future | The asset exists but has not been activated. | | `64` | Active | The asset has been assigned to a user-defined workflow status. | | `128` | Closed | The asset has been closed or quick-closed. | | `200` | Template (dead) | Used only to create new copies via Templates or Quick Add. | | `208` | Broken Down (dead) | The asset was converted to an Epic for further break-down. | | `209` | Collaborator (dead) | Members only: a guest collaborator, not a full member. | | `254` | Deprecated (dead) | Retired by the system, such as subscriptions to a removed feature. | | `255` | Deleted (dead) | The asset has been deleted. | :::note Deletion is not erasure Deleted assets are not permanently removed. They are marked with an asset state indicating they have no further value to the system. If an asset is significant or has historical importance, consider **closing** it when it reaches maturity rather than deleting it. ::: This is why almost every real-world filter carries a clause like `AssetState!='255'` — without it you get the tombstones back too. Filters on `AssetState` match bands rather than exact values, and `AssetState!='128'` means "below Closed", so it also excludes dead and deleted assets. See [AssetState values](/reference/attribute-types#assetstate-values) before filtering on it. ## Attribute Definition Attribute definitions describe the properties that make up each asset type. Each one declares the type of data it holds and whether it is required, read-only, multi-value, and more. Attribute definition names are unique within their asset type. An attribute definition is either a **value type** (a scalar) or a **relation** to other assets. Relations are further either single-value or multi-value: - `Estimate` on `Workitem` is a scalar — specifically a number. - `Scope` on `Workitem` is a **single-value relation** to a `Scope` asset. - `Workitems` on `Scope` is the reciprocal **multi-value relation** back to `Workitem` assets. Discover the full set for any type through [`meta.v1`](/apis/meta-v1). Compose them into paths using the [attribute definition syntax](/apis/conventions/attribute-definitions). ## Oid An **Oid** is a unique system identifier, not normally visible in the Agility user interface. An **Oid token** is the string form: the asset type name and an integer ID joined by a colon. ```text Member:20 Story:1220 Scope:0 ``` `Scope:0` is the system root project and is a useful anchor in examples. An Oid token may carry a third segment identifying a specific **moment** — a historical version of the asset: ```text Story:1220:4471 ``` An Oid is **not** the same as the user-visible `Number` attribute (`S-01047`, `D-00234`), which is available on many but not all assets and is meant for humans. ## Related Topics - [Glossary](/reference/glossary) — alphabetical quick reference for these and other terms - [Assets](/concepts/assets), [Attributes](/concepts/attributes), [Relations](/concepts/relations) - [Metadata API (meta.v1)](/apis/meta-v1) — discover asset types and attribute definitions at runtime --- # Dates and Date Ranges - URL: https://docs.digital.ai/agility-api/apis/conventions/dates - Markdown: https://docs.digital.ai/agility-api/apis/conventions/dates.md - Source: apis/conventions/dates.md - Description: How Agility stores date ranges. EndDate is exclusive — midnight of the day after the range ends — which is the source of most off-by-one errors against the API. - Tags: api, conventions, dates, ranges, timebox Agility stores a date range as a **half-open interval**: ```text BeginDate <= x < EndDate ``` `BeginDate` is included. **`EndDate` is excluded.** It is stored as midnight at the *start of the day after* the range ends. This one convention is behind most of the date confusion people hit against the API, because it does not match what the product displays. ## What That Means in Practice A Sprint that the UI shows as running **1 January to 7 January** is stored as: | Attribute | Value | | --- | --- | | `BeginDate` | `2020-01-01T00:00:00` | | `EndDate` | **`2020-01-08T00:00:00`** | The `EndDate` is a day later than the date on screen, and it is a midnight boundary rather than an end-of-day time. :::info Why the API and the UI disagree The product **translates in both directions and never shows you the stored value.** Its end-date editor adds a day when saving what you typed, and subtracts a day again when displaying it. The API does neither. It returns the stored value, exclusive, untranslated — so the first time most people meet this convention is when a date from the API is one day later than the same date in the product, and nothing explains why. Neither is wrong. The UI is showing you an inclusive date because that is what people mean by "ends on the 7th"; the API is showing you the boundary that is actually stored. ::: :::danger The filter that looks right is wrong ```text EndDate<='2020-01-07' ``` This **misses the sprint entirely**. Its `EndDate` is the 8th. Worse, the mistake does not error and does not obviously misbehave — it just quietly returns one fewer range than expected, which is exactly the kind of discrepancy that surfaces as "the API disagrees with the report". ::: ### Testing Whether a Date Falls in a Range Use the half-open form, and never `<=` on the upper bound: ```text BeginDate<=$Date;EndDate>$Date ``` ### Computing a Duration Because the interval is half-open, the subtraction is direct — there is **no off-by-one to correct**: ```text days = EndDate - BeginDate ``` `2020-01-08` minus `2020-01-01` is 7 days, which is the right answer for a sprint running the 1st through the 7th. If you find yourself adding one, you have probably also converted `EndDate` to an inclusive date somewhere else, and the two errors will cancel until they do not. ### Displaying a Range To show a user the same dates the product shows, subtract a day from `EndDate`: ```text displayed end = EndDate - 1 day ``` ## Consecutive Periods Share a Boundary The convention has a consequence that is easy to get wrong when you bucket time yourself: **each period's end is the next period's begin**, not the day before it. ```text period 0: [ 2020-01-01 , 2020-01-08 ) period 1: [ 2020-01-08 , 2020-01-15 ) period 2: [ 2020-01-15 , 2020-01-22 ) ``` `2020-01-08` appears twice — as the exclusive end of one period and the inclusive begin of the next. It belongs to period 1 only, and no day is counted twice. This is exactly how Agility builds its own time dimensions: it walks `while (begin < endDate)`, computes each bucket's end, and then assigns `begin = end` for the next iteration. Consecutive buckets sharing a value is only correct because the end is exclusive. **Do not subtract a day when generating adjacent periods.** If you build buckets as `[Jan 1, Jan 7]`, `[Jan 8, Jan 14]` — inclusive, non-overlapping — they will not line up with the product's, and items falling on a boundary will land in a different bucket or none at all. ## Which Attributes Follow This **Treat it as the pattern for any asset that defines a time range with a pair of dates**, not as a quirk of one or two types. Where an asset has a beginning and an end, the end is the exclusive boundary. Seven asset types define such a pair, under three different naming conventions: | Asset type | Range | Attributes | | --- | --- | --- | | `Timebox` | Sprints and iterations | `BeginDate` / `EndDate` | | `Scope` | Project start and end | `BeginDate` / `EndDate` | | `Budget` | Budget period | `BeginDate` / `EndDate` | | `OkrSession` | Objective period | `BeginDate` / `EndDate` | | `Timesheet` | Timesheet period | `BeginDate` / `EndDate` | | `CalendarEvent` | Event span | **`StartDate`** / `EndDate` | | `Epic` | Planned delivery window | **`PlannedStart`** / **`PlannedEnd`** | The naming varies but the convention does not. `Epic`'s planned window is edited by the same control that applies the day shift, so `PlannedEnd` is exclusive in exactly the way `EndDate` is — do not assume the differently-named pairs behave differently just because they read differently. **Single dates are not affected.** `DoneDate`, `ClosedDate`, `RetireDate` and `ChangeDate` are points in time rather than range boundaries, and carry no exclusivity convention. :::danger `meta.v1` cannot tell you any of this This is the part that makes the convention genuinely hazardous. Schema discovery reports `EndDate` as an attribute of type `Date`, and stops there. There is no flag, no naming rule and no metadata of any kind marking a date as an exclusive boundary. So a client that introspects the model — the correct thing to do for asset types, attributes and operations — **cannot learn this**. It has to be known in advance and applied by hand, which is precisely why it keeps being rediscovered through a support ticket. If you are generating code or mappings from `meta.v1`, this is one of the rules you have to encode yourself. ::: ## Related - [Reproducing reported numbers](/guides/reproducing-reported-numbers) — other reasons a figure disagrees with the product - [Moments and history](/concepts/moments-and-history) — `asof` and point-in-time queries - [Filter expressions](/reference/query-grammar/filter-expressions) — comparison operators --- # Bulk API (api/asset) - URL: https://docs.digital.ai/agility-api/apis/asset-bulk - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk.md - Source: apis/asset-bulk/index.md - Description: Create, update, query and invoke operations on many assets in a single request against ~/api/asset. - Tags: api, bulk, asset, batch The Bulk API lets you create, update, query, and invoke operations on one or more assets with a single HTTP request, using either JSON or YAML. It builds on the SQL-like syntax originally available in [`query.v1`](/apis/query-v1). ```text POST ~/api/asset Content-Type: application/json (or text/yaml) ``` :::tip If you only learn one endpoint, learn this one `api/asset` is the closest thing Agility has to a one-stop shop: queries, creates, updates and operations all go through the same endpoint, in the same request if you want. It is arguably the simplest, most intuitive and most powerful surface in the product. Two things it is **not**: - **Not a server-side speed optimisation.** Batching saves round trips between your client and the server. The server does the same work either way. - **Not one big transaction.** It deliberately splits the work into several. That is a feature: unlike a spreadsheet import, a large `api/asset` job will not force the server to block everyone else's updates until it finishes. ::: A few behaviours in this section are flagged as unverified on their page — treat those with more care than the rest. ## What Makes This Endpoint Different Unlike [`rest-1`](/apis/rest-1) and [`query.v1`](/apis/query-v1), a single `api/asset` request can mix command kinds freely: | Command kind | Shape | Page | | --- | --- | --- | | Create | `AssetType` + attributes | [Creating assets](/apis/asset-bulk/creating-assets) | | Update | `from` + optional `where` / `filter` / `with` + `update` | [Updating assets](/apis/asset-bulk/updating-assets) | | Execute operation | `from` + optional `where` / `filter` / `with` + `execute` | [Operations](/apis/asset-bulk/operations) | | Query | `from` + `select` / `where` / `filter` / `sort` | [Queries](/apis/asset-bulk/queries) | | Configuration | `config` | [Request and response format](/apis/asset-bulk/request-format) | The server fulfils every request in **two phases** — projection, then processing. Understanding that split is the key to understanding aliases, hierarchy flattening, deferrals, and preview mode. Start with [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection). ## A First Request ```json title="POST ~/api/asset" { "AssetType": "Story", "Scope": "Scope:0", "Name": "First Story" } ``` ```json title="Response" { "assetsCreated": { "oidTokens": ["Story:1585"], "count": 1 }, "assetsModified": { "oidTokens": [], "count": 0 }, "assetsOperatedOn": { "oidTokens": [], "count": 0 }, "commandFailures": { "commands": [], "count": 0 }, "queryResult": { "results": [], "count": -1 } } ``` The same request in YAML: ```yaml title="POST ~/api/asset (Content-Type: text/yaml)" AssetType: Story Scope: Scope:0 Name: First Story ``` ## Section Contents - [Request and response format](/apis/asset-bulk/request-format) — content types, the JSON-array / YAML-multi-document equivalence, the response envelope, `resultMetaData`, and `config counts-only`. - [Creating assets](/apis/asset-bulk/creating-assets) — single, multiple, hierarchies, scope by name vs. OID token, relations set from a subquery. - [Updating assets](/apis/asset-bulk/updating-assets) — update one asset, update everything matching a query, ordering, updating deleted assets. - [Updating multi-value relations](/apis/asset-bulk/relation-updates) — the full add/remove grammar, including the object-keys form and subqueries. - [Find and replace](/apis/asset-bulk/find-and-replace) — conditional replacement on scalar text attributes and on relations. - [Operations](/apis/asset-bulk/operations) — `execute` in its bare, `args`, `list`, and subquery forms; Delete and Undelete. - [Queries](/apis/asset-bulk/queries) — single and multiple independent result sets, nested selects, querying an alias. - [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection) — the two-phase model, auto-aliasing, user-aliasing, request-hierarchy flattening, and deferrals. - [Preview mode](/apis/asset-bulk/preview-mode) — `?previewOnly=true`, and round-tripping a projection back to the server. - [Errors and partial failures](/apis/asset-bulk/errors) — the failure-cascade shape, `sourceCommandIndex`, `#ContextOid`, and safe reissue. - [Compound requests](/apis/asset-bulk/compound-requests) — the capstone example that combines every feature. ## Related Topics - [Choosing an API](/getting-started/choosing-an-api) - [Query API (query.v1)](/apis/query-v1) - [Operations](/concepts/operations) - [Assets](/concepts/assets) --- # Request and Response Format - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/request-format - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/request-format.md - Source: apis/asset-bulk/request-format.md - Description: Content types, the JSON-array and YAML-multi-document forms, the response envelope, resultMetaData, and config counts-only. - Tags: api, bulk, asset, reference ## Endpoint and Content Type ```text POST ~/api/asset ``` The endpoint accepts two content types, and every example in this section is proven in both: | Content-Type | Body | | --- | --- | | `application/json` | A single command object, or an array of command objects | | `text/yaml` | A single YAML document, or several documents separated by `---` | The two are exactly equivalent. A JSON array of _n_ commands and a YAML stream of _n_ documents produce identical results. ```json tab title="Two commands" [ { "AssetType": "Story", "Name": "My First Story", "Scope": "System (All Projects)" }, { "AssetType": "Story", "Name": "My Second Story", "Scope": "Scope:0" } ] ``` ```yaml tab title="The same two commands" AssetType: Story Name: My First Story Scope: System (All Projects) --- AssetType: Story Name: My Second Story Scope: Scope:0 ``` A single command does **not** need to be wrapped in an array. Sending one bare JSON object is equivalent to sending a one-element array; the projection phase normalises it. See [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection). ### Query-String Parameters | Parameter | Effect | | --- | --- | | `?deleted=1` | Allow the request to consider deleted assets. See [Querying deleted assets](/apis/asset-bulk/queries#querying-deleted-assets) | | `?previewOnly=true` | Stop after projection and return the projection. See [Preview mode](/apis/asset-bulk/preview-mode) | :::warning A missing body is a hard 500 `POST ~/api/asset` with no body returns **HTTP 500**, not a `commandFailures` entry. The graceful partial-failure model described in [Errors and partial failures](/apis/asset-bulk/errors) applies to commands the server was able to parse; a request it cannot parse at all fails outright. ::: ## The Response Envelope Every successful request returns HTTP `200 OK` with the same top-level envelope, regardless of which command kinds it contained: ```json { "assetsCreated": { "oidTokens": [], "count": 0 }, "assetsModified": { "oidTokens": [], "count": 0 }, "assetsOperatedOn": { "oidTokens": [], "count": 0 }, "commandFailures": { "commands": [], "count": 0 }, "queryResult": { "results": [], "count": -1 } } ``` | Property | Meaning | | --- | --- | | `assetsCreated` | OID tokens of assets created, in the order the create commands appeared | | `assetsModified` | OID tokens of assets an `update` command addressed — matched assets count even when a [find-and-replace](/apis/asset-bulk/find-and-replace) left them untouched | | `assetsOperatedOn` | OID tokens of assets an `execute` command ran against | | `commandFailures` | One entry per failed command; see [Errors](/apis/asset-bulk/errors) | | `queryResult` | One entry in `results` per query in the request | :::warning A 200 does not mean everything succeeded The Bulk API returns `200 OK` even when individual commands fail. Partial failure is reported in `commandFailures`, not in the HTTP status code. Always check `commandFailures.count`. Every error test in the suite asserts `res.status === 200` alongside a non-empty `commandFailures`. ::: ### Ordering Guarantees `assetsCreated.oidTokens` is ordered to match the order of the create commands after hierarchy flattening — parent first, then its children in the order they were declared. Tests rely on this by destructuring the array positionally, for example: ```ts const [scopeOidToken, epicOidToken, story1, story2, story3, task, test] = res.data.assetsCreated.oidTokens; ``` ### The Same Token Can Appear Twice `assetsOperatedOn.oidTokens` may contain the same OID token more than once when an operation is invoked repeatedly against a single asset — for example when using the `list` form of `execute`. An OID token can also appear in **both** `assetsCreated` and `assetsOperatedOn` when a request creates an asset and then invokes an operation on it. See [Compound requests](/apis/asset-bulk/compound-requests). ## Processing Model: Large Batches Run to Completion A single `api/asset` request can carry many commands — hundreds or thousands of asset updates in one array. How the server processes that batch has consequences worth understanding before you send a large one: it applies immediately, one command at a time, and no single action undoes the batch once it has run. The "bulk" in the Bulk API refers to how many operations you can submit in one request, not to how they run. The server executes them one at a time, each as its own short transaction. That is a deliberate choice: many small transactions keep the server's footprint low while the batch runs — no long-held, monolithic transaction holding locks across thousands of changes — at the cost of atomicity and raw throughput. So a bulk request saves you round trips and stays gentle on the server, but gives you none of the all-or-nothing guarantee a single batched transaction would. **There is no enforced time limit on processing a request.** A batch of a thousand updates takes as long as it takes; the server does not cap the work or abort a long-running batch. Set a generous **client** timeout accordingly — but understand what a client timeout does and does not do (see below). **Each command is its own database transaction — the request is not atomic.** The Bulk API applies commands one at a time, committing each before moving to the next; it is [not transactional](/apis/asset-bulk/errors). A batch that fails partway leaves the already-processed commands applied and reports the rest in `commandFailures`. There is no single enclosing transaction to roll back. (This per-command commit is a deliberate trade: it is gentler on the server than applying an entire large batch in one long-held transaction would be, at the cost of atomicity — a partly-applied batch is a state you must be prepared to handle, not one the server will undo for you.) **Once submitted, a batch cannot be cancelled — and a timeout does not stop it.** There is no cancel endpoint. A timeout you observe is almost always at a proxy or gateway in front of the server: once the request reaches the point where the server has read the body, processing runs to completion regardless of whether anything is still listening for the response. A client or gateway timeout therefore tells you nothing about how much was applied — you can be left with a fully- or partly-applied mutation and **no response to say which**. After a timeout on a mutating batch, re-query to discover the actual state rather than assuming the request did nothing, and **never blindly resubmit the original payload** — the commands that already committed would run again. **Individual changes are reversible, but there is no batch-level undo.** Every update and every operation Agility applies is recorded in the asset's [history as a moment](/concepts/moments-and-history), so a change can be traced and reverted one asset at a time. What you do not get is a single "undo this batch" action: unwinding a large accidental batch means finding every asset it touched and reversing each. That is exactly the reconciliation you want to avoid having to do — so the discipline is to prevent the bad batch, and to make its footprint findable if one slips through. :::warning A large mutating batch is not atomic and has no batch undo — preview and stamp it Before sending a large or destructive batch: - **Preview it, and keep the response.** [`?previewOnly=true`](/apis/asset-bulk/preview-mode) expands and validates the whole request without applying anything — the one safe dry run you get, and the front-line defense against a mis-scoped update or a mass `Close`/`Delete` hitting more assets than you intended. Because the API returns no durable receipt of what a live run committed, that preview response is the best record you have of what the batch _would_ do — and since the projection [round-trips](/apis/asset-bulk/preview-mode#the-projection-round-trips) as a valid request body, a saved preview doubles as your reissue payload. Treat it as a record of intent, not proof of effect: state can change between preview and commit, so reconcile against a re-query afterward. - **Stamp updates with a change comment so you can find them later.** An `update` command may carry a [`ChangeComment`](/apis/asset-bulk/updating-assets#recording-a-change-comment) inside its `update` node, which becomes the audit comment on every moment the command creates. A distinctive comment turns "find everything that batch touched" into a single history query. (Note: `execute` operation commands do **not** accept a `ChangeComment` — only create and update commands do — so a mass-operation batch such as `Close` or `Delete` cannot be tagged this way. Previewing those matters all the more.) - **Size batches for recoverability, not just throughput.** Smaller batches mean a partial failure or a timeout leaves less to reconcile, and the [failure list is directly resubmittable](/apis/asset-bulk/errors#reissuing-a-failed-request). ::: ## `queryResult` `queryResult.count` is the number of **result sets**, not the number of assets. A request with no queries returns `"count": -1`. A request with two queries returns `"count": 2`, even if both are empty. ```json "queryResult": { "resultMetaData": [ { "page": { "start": 0, "size": 2147483647 }, "total": 1 } ], "results": [ [{ "_oid": "Story:1585", "Name": "First Story" }] ], "count": 1 } ``` ### `resultMetaData` Alongside `results`, the response carries a parallel `resultMetaData` array with one entry per result set: | Property | Meaning | | --- | --- | | `page.start` | Zero-based index of the first row returned | | `page.size` | Page size; defaults to `2147483647` when unpaged | | `total` | Total matching assets ignoring paging — but `-1` unless `needTotal` is set | :::warning `total` is opt-in `total` is only computed when the query command sets `needTotal: true` (or the instance's `ApiDefaultNeedTotal` setting turns it on). Otherwise it is `-1`, meaning "not computed". See [Paging and totals](/apis/asset-bulk/queries#paging-and-totals). ::: ## `config counts-only` A request can suppress the OID token arrays and return only counts by placing a `config` command in the request. This is a request-level setting expressed as its own command, so it occupies a position in the command list. ```json tab [ { "config": "counts-only" }, { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story", "Children": [{ "AssetType": "Test", "Name": "My Test" }] } ] ``` ```yaml tab config: counts-only --- AssetType: Story Scope: Scope:0 Name: My Story Children: - AssetType: Test Name: My Test ``` The affected response properties then omit `oidTokens` entirely rather than returning it empty: ```json "assetsCreated": { "count": 2 } ``` Use this when you are applying a very large batch and do not need to correlate the results — it keeps the response small. :::warning Not covered by tests The tests only exercise the literal value `counts-only`. Whether other `config` values exist, and whether `counts-only` also suppresses `queryResult` detail, is not verified here. ::: ## Related Topics - [Creating assets](/apis/asset-bulk/creating-assets) - [Queries](/apis/asset-bulk/queries) - [Errors and partial failures](/apis/asset-bulk/errors) - [HTTP status codes](/reference/status-codes) --- # Creating Assets - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/creating-assets - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/creating-assets.md - Source: apis/asset-bulk/creating-assets.md - Description: Create one asset, many assets, or a whole hierarchy in a single Bulk API request. - Tags: api, bulk, asset, create A create command is any command object that carries an `AssetType` property. Everything else in the object is either an attribute value, a relation value, or a nested hierarchy of further create commands. ## A Single Asset ```json { "AssetType": "Story", "Scope": "Scope:0", "Name": "First Story" } ``` ```json title="Response" { "assetsCreated": { "oidTokens": ["Story:1585"], "count": 1 }, "assetsModified": { "oidTokens": [], "count": 0 }, "assetsOperatedOn": { "oidTokens": [], "count": 0 }, "commandFailures": { "commands": [], "count": 0 }, "queryResult": { "results": [], "count": -1 } } ``` ## Many Assets Send an array (JSON) or a multi-document stream (YAML). Asset types may be mixed freely. ```json [ { "AssetType": "Story", "Scope": "Scope:0", "Name": "Second Story" }, { "AssetType": "Story", "Scope": "Scope:0", "Name": "Third Story" }, { "AssetType": "Defect", "Scope": "Scope:0", "Name": "First Defect" } ] ``` ## Referencing a Relation by OID Token or by Name A single-value relation can be given either an OID token or the target asset's **name**. Both forms resolve to the same asset. ```yaml title="By name" AssetType: Story Name: My Story Scope: System (All Projects) ``` ```yaml title="By OID token" AssetType: Story Name: My Story Scope: Scope:0 ``` A name is matched against the target type's naming attributes — `Name` for most types, `Name` or `Number` for types that carry a display number, and `Name`, `Nickname`, `Username`, or `Email` for a Member — and deleted assets never match. :::note `Role.Name'Project Admin` is a name, not a syntax Some built-in assets store a localization tag as their `Name`; every Role is named `Role.Name'`, for example (see [roles and permissions](/reference/roles-and-permissions)). So this is an ordinary lookup by name: ```yaml AssetType: Member Name: User1 Username: User1 Password: User1 Nickname: User1 DefaultRole: Role.Name'Project Admin ``` There is no `.'` form for relation values. A value of `Scope.Name'Optimize` looks for a Scope literally named `Scope.Name'Optimize`, and fails. ::: ### Qualifying a Name with a Path A bare name only works when it is unambiguous. When it is not, qualify it with a backslash-separated **path** through the asset's naming context: ```yaml AssetType: Story Name: My Story Scope: Offerings\Agility 25.2\Optimize ``` The path needs to be **only deep enough to resolve the name uniquely** — it does not have to start at the root. Everything after the last backslash is the target asset's name; everything before it is context. :::warning Backslashes in a name must be doubled The last **unescaped** backslash is what separates context from name, so a backslash that is part of an asset's own name must be written `\\`. An asset genuinely called `Q1\Q2 Planning` inside `Offerings` is therefore written `Offerings\Q1\\Q2 Planning`. Get this wrong and the name splits in the wrong place, resolving to a different asset or to none. ::: **The context may itself be an OID token.** Each segment is resolved by trying it as an OID token first and falling back to a name lookup, so `Scope:1003\Optimize` is as valid as spelling out the parent by name — useful when you know the container's OID but not the child's. #### Which types accept a path {#path-capable-types} A path walks a single relation, the target type's **naming context**, and only the types below (and the types derived from them) have one. Any other type reads the whole value, backslashes and all, as one literal name. | Target type | A segment matches on | Naming context | | --- | --- | --- | | `Scope` | `Name` | `Parent`, another Scope | | `Workitem` and every type derived from it — `Story`, `Defect`, `Epic`, `Task`, `Test`, `TestSet`, `Theme` | `Name` or `Number` | `Scope` | | `Issue`, `Request`, `Goal`, `StrategicTheme`, `Environment`, `RegressionTest`, `RegressionPlan` | `Name` or `Number` | `Scope` | | `Room` | `Name` | `Scope` | | `RegressionSuite` | `Name` or `Number` | `RegressionPlan` | | `Timebox` | `Name` | `Schedule` | | `StoryStatus` | `Name` | `Team` | Two consequences follow: - **A segment can be a Number where the table allows it.** `Parent: S-01001` resolves a Task's parent by display number, and `Acme\S-01001` narrows the same lookup to one project. - **A path can cross types.** Each context segment is resolved by the context type's own rule, so `Offerings\Agility\Production` as an `Environment` value finds `Production` inside the Scope that `Offerings\Agility` resolves to, which is in turn the `Agility` child of the `Offerings` Scope. The chain ends at the first type with no naming context: `Acme\Platform` as a `Team` value is a Team literally named `Acme\Platform`. #### Find a type's naming context {#naming-context-discovery} `meta.v1` does not report it. The rule is an attribute of the `AssetType` metadata asset, which any query endpoint can read with any member's token, whatever its role: ```bash title="rest-1.v1" curl -H "Authorization: Bearer " \ "${AGILITY_BASE_URL}/rest-1.v1/Data/AssetType?sel=Name,Base.Name,NameResolutionAttributes,Resolver&where=Name='Scope'&page=5,0" ``` ```json title="The same read as a Bulk API query" { "from": "AssetType", "select": ["Name", "Base.Name", "NameResolutionAttributes", "Resolver"], "where": { "Name": "Scope" }, "page": { "size": 5, "start": 0 } } ``` ```json title="Result (one row)" { "_oid": "AssetType:-5034", "Name": "Scope", "Base.Name": "BaseAsset", "NameResolutionAttributes": "Name,Parent", "Resolver": "VersionOne.Meta.RelatedAssetResolver,VersionOne" } ``` Read it like this: - **`Resolver` set:** the last entry of `NameResolutionAttributes` is the naming context, and the entries before it are what a segment matches on. - **`Resolver` empty:** the type has no naming context. The whole value is matched as a name against each listed attribute in turn, which is why a `Member` (`Name,Nickname,Username,Email`) can be referenced by any of the four. - **Both empty:** the type inherits its base type's rule. `Story` reports nothing of its own; follow `Base` to `PrimaryWorkitem` and then to `Workitem`. ### How a Relation Value Is Interpreted The rule is the same for every relation attribute, and it is worth knowing because it explains why names work at all: 1. The value is tried as an **OID token**. 2. If that fails, it is treated as a **moniker** and resolved by name, path and all. There is no flag and no syntax to choose between them — the fallback is automatic. A consequence worth noting: a mistyped OID token does not fail as a bad token, it becomes a name lookup, and the error you get back is about an unresolvable name rather than a malformed identifier. The same resolution applies when setting a relation in an **update** command, not just a create. See [Updating assets](/apis/asset-bulk/updating-assets) and [multi-value relations](/apis/asset-bulk/relation-updates). #### A moniker must resolve to exactly one asset {#moniker-must-be-unique} Name resolution has three outcomes, and only one of them sets the relation: | Matches | Outcome | `error.message` | | --- | --- | --- | | Exactly one | The relation is set | — | | None | The command fails | `Resolution'Unresolved'.'` | | More than one | The command fails | `Resolution'Ambiguous'.'` | `Resolution'Ambiguous'Story.Scope'Scope` names the attribute being set and the type it searched; the value you sent is not echoed back. The failure is per command: the rest of the request still runs, and the failed command comes back in `commandFailures` ready to correct and resubmit (see [errors and partial failures](/apis/asset-bulk/errors)). A path does not change the rule, it narrows the candidates. `Acme\Optimize` that still matches two child Scopes of `Acme` named `Optimize` is still `Resolution'Ambiguous'`; the only unambiguous reference to one of them is its OID token. Resolution searches every asset of the type, not just the ones you can see. A member with access to only one of two Scopes named `Moniker Test` sees one match when querying for that name, yet `Scope: Moniker Test` still fails with `Resolution'Ambiguous'`. When a query returns exactly one asset and the name still fails as ambiguous, the duplicate is one you cannot see: use the OID token from your query result. :::note The same rule applies outside the API Agility's Excel import resolves relation columns the same way and reports the same two outcomes in words — "Unable to resolve attribute value for asset type …" and "Multiple … assets matching provided name". An import that fails on a duplicate name is the ambiguity a Bulk request reports as `Resolution'Ambiguous'`, with the same remedies: qualify the value with a path, or use the OID token. ::: ## Creating List Values The values behind a dropdown — statuses, priorities, defect types, and any custom list an administrator has added — are **ordinary assets**, not a special kind of configuration. Each list is an asset type deriving from the abstract `List` type, and each value in it is one asset of that type. So populating a list is just creating assets: ```json [ { "AssetType": "Custom_RiskTier", "Name": "Low" }, { "AssetType": "Custom_RiskTier", "Name": "Medium" }, { "AssetType": "Custom_RiskTier", "Name": "High" } ] ``` Custom list types carry the `Custom_` prefix, matching the convention for custom attributes. Built-in lists use their own names — `StoryStatus`, `WorkitemPriority`, `DefectType` and so on. Confirm the type name against [`meta.v1`](/apis/meta-v1) rather than guessing it; list type names are not localized the way [role names](/reference/roles-and-permissions) are, but they do vary with the methodology the instance was installed with. Some list types carry more than a `Name`. `StoryStatus`, for example, takes a `RollupState` and can be scoped to a `Team`: ```json { "AssetType": "StoryStatus", "Name": "In Progress", "RollupState": "64", "Team": "@team" } ``` Check the type in `meta.v1` for what it requires. :::tip This is where the Bulk API earns its keep Creating list values one at a time through `rest-1.v1` is a request per value. The Bulk API takes the whole set in a single call, which turns populating a several-thousand-entry list from a long-running script into one request. The same [size considerations](/apis/asset-bulk/request-format) apply as for any large request — a very large list is still better split into a few batches than sent as one enormous body. ::: ## Creating a Hierarchy Nest child create commands inside a relation property on the parent. Any relation that accepts child assets works — `Children`, `Subs`, `Workitems`. ```json { "AssetType": "Epic", "Scope": "Scope:0", "Name": "Epic with Subs", "Category": "Epic", "Subs": [ { "AssetType": "Epic", "Name": "Feature Epic", "Category": "Feature", "Subs": [ { "AssetType": "Story", "Name": "Feature Story 1", "Children": [ { "AssetType": "Task", "Name": "Code the feature" }, { "AssetType": "Test", "Name": "Test the feature" } ] } ] } ] } ``` ```json title="Response" { "assetsCreated": { "oidTokens": ["Epic:1589", "Epic:1590", "Story:1591", "Task:1592", "Test:1593"], "count": 5 }, "assetsModified": { "oidTokens": [], "count": 0 }, "assetsOperatedOn": { "oidTokens": [], "count": 0 }, "commandFailures": { "commands": [], "count": 0 }, "queryResult": { "results": [], "count": -1 } } ``` Nesting is not limited in depth, and the child does not repeat the `Scope` — it inherits its context from the containing asset. Internally the server flattens the hierarchy into a linear command list before processing it; see [Request hierarchy flattening](/apis/asset-bulk/aliases-and-projection#request-hierarchy-flattening). ### Omitted Attributes Are Inherited from the Containing Asset A nested create command that omits `Name` and `Description` inherits them from its parent. ```yaml AssetType: Epic Name: Epic name Description: Epic description Subs: - AssetType: Story Name: My own name Description: My own description - AssetType: Story # inherits "Epic name" / "Epic description" - AssetType: Story Name: Story with Children Description: Story with Children that has its own Description ``` ## Setting a Multi-Value Relation from a Subquery A relation value in a create command can be a **subquery** instead of a literal reference. The subquery runs during processing and its matches become the relation's members. ```yaml AssetType: Story Name: I need to drop one of these Owners! Scope: Remove one owner Owners: from: Member filter: ["Name='Administrator','User2'"] ``` :::tip Subquery-valued relations are the cleanest way to write portable setup scripts: they let you reference assets by a stable business value rather than by an OID token that differs per instance. ::: ## Recording a Change Comment {#recording-a-change-comment} Add `ChangeComment` at the **command root**, alongside `AssetType`, to attach an audit comment to the creation: ```json { "AssetType": "Story", "Scope": "Scope:0", "Name": "New Story", "ChangeComment": "Imported from tracker" } ``` It is recorded as the change's comment, not as an attribute of the new asset, and reads back from history as `ChangeComment`. (On an **update** command it goes inside the `update` node instead — see [updating assets](/apis/asset-bulk/updating-assets#recording-a-change-comment).) When you do not supply one, the Bulk API stamps a default: a create's history entry reads **`Created through api/asset`**. This marks Bulk API writes in the audit trail on its own — `rest-1.v1` writes no comment at all unless one is supplied. Supply your own `ChangeComment` when the integration has something more specific to say. ## Creating Assets Through an Update Command Assets can also be created as a side effect of an update command, by nesting create commands inside a relation being updated: ```yaml from: Scope:0 update: Workitems: - AssetType: Story Name: Story 1 - AssetType: Story Name: Story 2 - AssetType: Epic Name: Epic that will get new Subs ``` The response reports these in `assetsCreated`, and the updated container in `assetsModified`. See [Updating multi-value relations](/apis/asset-bulk/relation-updates#mixing-existing-references-with-new-assets). ## Naming a Create Command with an Alias Wrapping a create command in a user alias lets later commands in the same request refer to the asset before it exists: ```json [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "Story Time" } }, { "AssetType": "Test", "Parent": "@story", "Name": "Test Time" } ] ``` See [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection). ## Related Topics - [Request and response format](/apis/asset-bulk/request-format) - [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection) - [Errors and partial failures](/apis/asset-bulk/errors) - [Assets](/concepts/assets) --- # Updating Assets - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/updating-assets - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/updating-assets.md - Source: apis/asset-bulk/updating-assets.md - Description: Update a single asset by OID token, or every asset matching a query, in one Bulk API request. - Tags: api, bulk, asset, update An update command is a command object with a `from` clause and an `update` clause. `from` selects the target — either a single OID token or an asset type narrowed by `where` or `filter`. ## Updating a Single Asset ```json { "from": "Story:1585", "update": { "Name": "Update members", "Description": "Given I am an admin, when I open a member's profile, I can edit fields." } } ``` ```json title="Response" { "assetsCreated": { "oidTokens": [], "count": 0 }, "assetsModified": { "oidTokens": ["Story:1585"], "count": 1 }, "assetsOperatedOn": { "oidTokens": [], "count": 0 }, "commandFailures": { "commands": [], "count": 0 }, "queryResult": { "results": [], "count": -1 } } ``` ## Updating Everything That Matches a Query Give `from` an asset type and add a `where` (attribute equality) or `filter` (full [query.v1 filter expression](/apis/query-v1)) clause — `with` [variables](/reference/query-grammar/filter-context) work in the filter here exactly as they do in a query, arrays included. Every matching asset receives the same update. ```json { "from": "Workitem", "where": { "Parent": "Story:1591" }, "update": { "Owners": ["Member:20", "Member:1575"] } } ``` ```json title="Response" { "assetsModified": { "oidTokens": ["Task:1592", "Test:1593"], "count": 2 } } ``` :::warning There is no implicit limit `from: Story` with no `where` matches every Story the calling identity can see. Use [preview mode](/apis/asset-bulk/preview-mode) to see exactly which assets a query-scoped update will touch before you run it. ::: ## Scalar and Single-Value Relation Attributes Scalars and single-value relations use the same syntax — assign the value directly. A single-value relation accepts an OID token or a name, optionally [qualified with a path](/apis/asset-bulk/creating-assets#qualifying-a-name-with-a-path); the name must resolve to exactly one asset. ```json { "from": "Task:1489", "update": { "Description": "TODO -- get details from Andre Agile", "Status": "In Progress" } } ``` Internally both are projected into the same `add` / `remove` shape used for multi-value relations. That normalisation is visible in [preview mode](/apis/asset-bulk/preview-mode): ```json [ { "@526e8128-41be-44ef-8401-bb3bd19b03ef": { "oid": "Task:1489", "update": { "Description": { "add": ["TODO -- get details from Andre Agile"], "remove": [] }, "Status": { "add": ["In Progress"], "remove": [] } } } } ] ``` The projection does not distinguish scalar from relation. The processing phase resolves the attribute definition and applies the value appropriately. For multi-value relations, the `add` / `remove` structure becomes meaningful and gains several convenience forms — see [Updating multi-value relations](/apis/asset-bulk/relation-updates). ## Reordering with The `Order` Attribute `Order` is a **`Rank`** attribute, and rank attributes work differently from every other type in two ways that matter. :::warning Never read a rank value — sort by it The value of a rank attribute is an **opaque token whose only meaning is relative to other ranks**. It is not a position, not an index, and not a number you can reason about. Reading it, storing it, comparing it arithmetically or trying to compute a "next" value will all mislead you, and the representation is free to change. The supported way to consume ordering is to **sort by the attribute** and take the sequence you get back: ```yaml from: Story where: Scope: Scope:1000 sort: - +Order ``` This applies to every `Rank` attribute, not just `Story.Order`. Most asset types have one — `Defect.Order`, `Epic.Order`, `Issue.Order`, and an `Order` on nearly every list type, which is what drives the sequence of values in a dropdown. ::: Writing follows the same principle: **you do not assign a rank, you name a neighbour.** The `Order` attribute takes a positional object rather than a value, identifying the sibling the asset should sit before or after. Both `Before` and `After` are supported, and each takes the OID token of a sibling. ```yaml title="Move Story B before Story A" from: Story:1002 update: Order: Before: Story:1001 ``` ```json title="Move Story C after Story D" { "from": "Story:1003", "update": { "Order": { "After": "Story:1004" } } } ``` Verify with a sorted query: ```yaml from: Story where: Scope: Scope:1000 sort: - +Order ``` ## Updating a Deleted Asset Deleted assets are excluded from queries by default. To target one, add `IsDeleted: true` to the `where` clause — even when `from` is an explicit OID token. ```yaml from: Story:1591 where: IsDeleted: true update: Owners: Member:20 ``` The update succeeds and is reported in `assetsModified` as normal. The asset remains deleted. The same rule applies to [operations](/apis/asset-bulk/operations#undelete) — `Undelete` needs `IsDeleted: true` to find its target. ## Recording a Change Comment Include `ChangeComment` **inside the `update` node** to attach an audit comment to the change: ```json { "from": "Story:1024", "update": { "Estimate": "5", "ChangeComment": "Re-estimated after grooming" } } ``` `ChangeComment` is not stored as an attribute — it becomes the change's audit comment, read back from history as [`ChangeComment`](/concepts/moments-and-history#reading-history-as-a-relation). On a **create** command it goes at the command root instead, alongside `AssetType` (see [creating assets](/apis/asset-bulk/creating-assets#recording-a-change-comment)). Omit it and the change is stamped **`Modified through api/asset`** (creates get `Created through api/asset`). The default distinguishes Bulk API writes from UI edits in the audit trail; `rest-1.v1` stamps nothing. ## Unknown Attributes Naming an attribute that does not exist on the target asset type produces a command failure, not an HTTP error. The snippet below shows only the `commandFailures` member of the response — the full envelope still carries `assetsCreated`, `assetsModified`, `assetsOperatedOn` and `queryResult` alongside it, as shown at the top of this page: ```json title="commandFailures (other response members omitted)" { "commands": [ { "@update": { "oid": "Scope:0", "update": { "MuddleBubble": { "add": ["BubbleMuddle"], "remove": [] } } }, "error": { "message": "Unknown AttributeDefinition: Scope.MuddleBubble", "sourceCommandIndex": 0 } } ], "count": 1 } ``` See [Errors and partial failures](/apis/asset-bulk/errors) for the full failure model. ## Related Topics - [Updating multi-value relations](/apis/asset-bulk/relation-updates) - [Find and replace](/apis/asset-bulk/find-and-replace) - [Preview mode](/apis/asset-bulk/preview-mode) - [Attributes](/concepts/attributes) --- # Updating Multi-Value Relations - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/relation-updates - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/relation-updates.md - Source: apis/asset-bulk/relation-updates.md - Description: The complete add/remove grammar for multi-value relations on the Bulk API, including object-keys, subquery, and mixed forms. - Tags: api, bulk, asset, update, relations Multi-value relations — `Owners`, `Subs`, `Children`, `Workitems`, `RegressionSuites` — accept a family of syntaxes that all normalise to the same projected form: ```json { "update": { "Owners": { "add": [ /* ... */ ], "remove": [ /* ... */ ] } } } ``` Everything below is a shorthand for filling those two arrays. Pick whichever form reads best for the job. ## Adding ### One Reference All five of these are equivalent. ```json title="Implicit single-add with scalar" { "from": "Task:1489", "update": { "Owners": "Member:20" } } ``` ```json title="Implicit single-add with array" { "from": "Task:1489", "update": { "Owners": ["Member:20"] } } ``` ```json title="Explicit single-add with object scalar" { "from": "Task:1489", "update": { "Owners": { "add": "Member:20" } } } ``` ```json title="Explicit single-add with object array" { "from": "Task:1489", "update": { "Owners": { "add": ["Member:20"] } } } ``` ```json title="Explicit single-add with object keys" { "from": "Task:1489", "update": { "Owners": { "Member:20": "add" } } } ``` Each projects to: ```json [ { "@bd9598ad-66af-4cec-b03e-bf273d91bfb5": { "oid": "Task:1489", "update": { "Owners": { "add": ["Member:20"], "remove": [] } } } } ] ``` ### Several References ```json title="Implicit multi-add with array" { "from": "Task:1489", "update": { "Owners": ["Member:20", "Member:1575"] } } ``` ```json title="Explicit multi-add with object array" { "from": "Task:1489", "update": { "Owners": { "add": ["Member:20", "Member:1575"] } } } ``` ```json title="Explicit multi-add with object keys" { "from": "Task:1489", "update": { "Owners": { "Member:20": "add", "Member:1575": "add" } } } ``` ## Removing Swap `add` for `remove`. The object-scalar and object-keys forms are the most convenient: ```json title="Explicit single-remove with object scalar" { "from": "Task:1489", "update": { "Owners": { "remove": "Member:20" } } } ``` ```json title="Explicit single-remove with object keys" { "from": "Task:1489", "update": { "Owners": { "Member:20": "remove" } } } ``` Object keys scale to several removals in one command: ```json { "from": "Epic:1600", "update": { "Name": "I have zero Subs", "Subs": { "Story:1601": "remove", "Story:1602": "remove" } } } ``` ## Adding and Removing in One Command ```yaml title="Object context pointing to scalars" from: Story:1700 update: Owners: remove: Member:20 add: Member:1800 ``` ```yaml title="Object context pointing to arrays" from: Story:1700 update: Owners: remove: - Member:20 add: - Member:1800 ``` ```json title="Object keys" { "from": "Story:1700", "update": { "Owners": { "Member:20": "remove", "Member:1800": "add" } } } ``` All three project to: ```json [ { "@ca414bbc-8dc5-41ef-9626-244b80145059": { "oid": "Story:1700", "update": { "Owners": { "add": ["Member:1800"], "remove": ["Member:20"] } } } } ] ``` :::tip `remove` is applied together with `add`, so it is safe to reference the same attribute in both — the result is the set difference plus the additions, not an ordering-sensitive sequence of edits. ::: ## Referencing by Subquery `add` and `remove` also accept a **subquery** in place of a literal list. The subquery runs during processing and every match participates. ```yaml title="Add every Story matching a filter" from: Epic:1900 update: Subs: add: from: Story filter: - ID='Story:1901','Story:1902','Story:1903' ``` ```yaml title="Remove every Story currently under this Epic" from: Epic:1900 update: Subs: remove: from: Story filter: - Super='Epic:1900' ``` :::warning Subquery cardinality differs by position In a **relation update**, every asset the subquery matches is used. In an [operation argument](/apis/asset-bulk/operations#subquery-arguments), only the _first_ match is used. Do not assume one behavior from the other. ::: ## Mixing Existing References with New Assets An `add` list may contain OID tokens and inline create commands side by side. The new assets are created and related in the same request. ```json { "from": "Epic:1850", "update": { "Name": "I should now be related to Story:1840 and another new Story via the Subs relation", "Subs": [ "Story:1840", { "AssetType": "Story", "Name": "I am a brand new Story" } ] } } ``` The response reports the Epic in `assetsModified` and the new Story in `assetsCreated`. The inline create is projected once **per matched asset**. Under a [query-scoped update](/apis/asset-bulk/updating-assets#updating-everything-that-matches-a-query) that means one new asset for every asset the `from`/`filter` selects, each related to its own parent — the shape to reach for when the same child belongs on many parents. ### Adding Only Where It Is Missing Because the filter runs before the creates are projected, a filter that excludes the parents already holding an equivalent child makes the whole command **idempotent**: run it twice and the second run creates nothing. This example puts a `Link` to a pull request on every listed asset that does not already carry that URL: ```json { "from": "BaseAsset", "filter": ["ID=$oids", "-Links[URL=$url]"], "with": { "$oids": ["Story:1227", "Defect:1310"], "$url": "https://github.com/example/repo/pull/12" }, "update": { "Links": [ { "AssetType": "Link", "Name": "GitHub PR", "URL": "https://github.com/example/repo/pull/12", "OnMenu": true } ] } } ``` `assetsModified` names the parents that received a Link; a parent the filter left out is absent from it and from `assetsCreated`. The check and the write happen inside one command, so there is no window between them for a second client to slip into — the closest thing the Bulk API offers to an idempotency key. :::warning Provisos for inline creates - **Use the array or `add` form.** A bare mapping directly under the relation — `"Links": { "AssetType": "Link", ... }` — is half-handled: the child is created and related, and the command is still reported in `commandFailures` with a StructureMap error naming `Relation`. Only the array under the relation, or `add:` with a list, is the supported syntax. - **Leave the update command unaliased.** In an array of commands, an alias on an update that carries an inline create makes every projected create fail with `The given key was not present in the dictionary`; the same command with no alias, in the same batch, succeeds. Aliases on the other commands in the batch are fine. - **One level only.** A create nested inside an inline create is projected after the child that needs it, so a required parent — an `Expression`'s `Conversation`, say — is not there yet and the child fails validation. An inline create also cannot reference an alias defined elsewhere in the same request. Create the dependency in its own command first and use its OID. - **Match exactly in the guard filter.** `-Links[URL=$url]` compares the stored value; `~=` is [full-text search](/reference/query-grammar/filter-expressions#comparison-operators) and its index catches up with writes asynchronously, so a guard written with `~=` still sees a child created seconds ago as missing and creates it again. ::: ## Replacing One Reference with Another To swap a reference only where it already exists, use [find and replace](/apis/asset-bulk/find-and-replace) rather than a paired `add`/`remove`. `add`/`remove` applies unconditionally to every matched asset; `find`/`replace` only touches assets that already hold the `find` value. ## Related Topics - [Updating assets](/apis/asset-bulk/updating-assets) - [Find and replace](/apis/asset-bulk/find-and-replace) - [Preview mode](/apis/asset-bulk/preview-mode) - [Relations](/concepts/relations) --- # Find and Replace - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/find-and-replace - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/find-and-replace.md - Source: apis/asset-bulk/find-and-replace.md - Description: Conditionally replace substrings in text attributes and swap references in multi-value relations across many assets at once. - Tags: api, bulk, asset, update `find` / `replace` is an update form that changes an attribute **only where the sought value is already present**. It works on scalar text attributes and on multi-value relations, and it is the tool of choice for mass edits where a blanket assignment would be wrong. ```json { "from": "", "where": { /* optional narrowing */ }, "update": { "": { "find": "", "replace": "" } } } ``` ## On Scalar Text Attributes For `Text` and `LongText` attributes, `find` matches a **substring** and `replace` substitutes it in place. The surrounding text is preserved. ```yaml from: Story where: Scope: Scope:1800 update: Name: find: Product-To-Be-Named-Later replace: Awesum.ly ``` Given two Stories named: ```text Product-To-Be-Named-Later is a marketing approved product name! Product-To-Be-Named-Later is for math wizards who want to do their calculations in the cloud. ``` they become: ```text Awesum.ly is a marketing approved product name! Awesum.ly is for math wizards who want to do their calculations in the cloud. ``` :::warning Substring, not token `find` is a plain substring match. Replacing `Cog` also rewrites `Cogent` and `Cognition`. There is no word-boundary or regular-expression support. Preview the change first — see [Preview mode](/apis/asset-bulk/preview-mode). ::: Matching is exact and case-sensitive in every test in the suite; no case-insensitive variant is exercised. ## On Multi-Value Relations For a relation, `find` and `replace` take references, and the semantics become "swap this reference for that one, but only where the first is already assigned". ```yaml from: Story where: Scope: Scope:1800 update: Owners: find: Member:20 replace: Member:1575 ``` This projects to a per-asset command that keeps the `find`/`replace` pair intact rather than resolving it to `add`/`remove` — the decision is deferred to processing, where each asset's current membership is known: ```json [ { "@33e9c481-29c7-4639-bb92-739be91f3da7": { "oid": "Task:1489", "update": { "Owners": { "find": "Member:20", "replace": "Member:1575" } } } }, { "@e3991edc-106e-4d5a-947d-e0421e250faa": { "oid": "Task:1490", "update": { "Owners": { "find": "Member:20", "replace": "Member:1575" } } } } ] ``` If `Task:1490` does not have `Member:20` as an owner, it is left untouched — `Member:1575` is **not** added to it. :::warning `assetsModified` counts matched assets, not changed ones An asset lacking the `find` value is left untouched — but it is still **counted in `assetsModified`**, exactly as if it had been changed. The count tells you how many assets the command addressed, not how many it altered. There is no response field that distinguishes the two; read the assets back if you need to know what actually changed. ::: ## Why Not `add` / `remove` ```json title="Wrong for a conditional swap" { "from": "Story", "where": { "Scope": "Scope:1800" }, "update": { "Owners": { "remove": "Member:20", "add": "Member:1575" } } } ``` This assigns `Member:1575` to **every** matched Story, including those that never had `Member:20`. Use `find` / `replace` when the replacement should be conditional on the original value. ## Related Topics - [Updating assets](/apis/asset-bulk/updating-assets) - [Updating multi-value relations](/apis/asset-bulk/relation-updates) - [Preview mode](/apis/asset-bulk/preview-mode) --- # Invoking Operations - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/operations - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/operations.md - Source: apis/asset-bulk/operations.md - Description: Execute Agility operations against one asset or every asset matching a query, with args, list, and subquery parameter forms. - Tags: api, bulk, asset, operations An execute command is a command object with a `from` clause and an `execute` clause. Assets it acts on are reported in `assetsOperatedOn`. ## The Bare Form When an operation takes no parameters, `execute` is just the operation name. ```json title="Single asset" { "from": "Task:1592", "execute": "Delete" } ``` `filter` (the full expression language) and `with` variables are accepted on an execute command exactly as on an update — `where` below is just the shortest form. ```json title="Every asset matching a query" { "from": "Workitem", "where": { "Parent": "Story:1591" }, "execute": "Delete" } ``` ```json title="Response" { "assetsOperatedOn": { "oidTokens": ["Task:1592", "Test:1593"], "count": 2 } } ``` Regardless of which form you use, the projection phase normalises `execute` into an operation name plus an argument bag: ```json { "oid": "Scope:0", "execute": { "op": "NonexistentOperation", "args": {} } } ``` ## The `args` Form — One Invocation with Parameters Use an object with `op` and `args` when the operation takes parameters. ```yaml from: Member:1040 execute: op: AssignToScopeWithRole args: Scope: Scope:1503 Role: Role:3 IsOwner: true ``` ```json { "from": "Member:1040", "execute": { "op": "AssignToScopeWithRole", "args": { "Scope": "Scope:1503", "Role": "Role:3", "IsOwner": true } } } ``` The response reports one entry in `assetsOperatedOn`. ## The `list` Form — Repeated Invocation `list` replaces `args` with an array of argument bags. The operation is invoked once per entry, against the same `from` target. ```yaml from: Member:1040 execute: op: AssignToScopeWithRole list: - Scope: Scope:1503 Role: Role:3 IsOwner: true - Scope: Scope:1504 Role: Role:7 IsOwner: false ``` ```json { "from": "Scope:1503", "execute": { "op": "AssignMemberWithRole", "list": [ { "Member": "Member:1040", "Role": "Role:3", "IsOwner": true }, { "Member": "Member:1041", "Role": "Role:7", "IsOwner": false } ] } } ``` The **same OID token appears once per invocation** in `assetsOperatedOn.oidTokens` — a two-entry `list` against one asset yields `count: 2` with the token repeated. ## Subquery Arguments Any value inside `args` may itself be a query. This lets a script address assets by a stable business value instead of an instance-specific OID token. ```yaml from: Member:1040 execute: op: AssignToScopeWithRole args: Scope: from: Scope where: Name: Subquery project for Member.AssignToScopeWithRole Role: from: Role where: Name: Role.Name'Project Lead IsOwner: true ``` ```json { "from": "Scope:1503", "execute": { "op": "AssignMemberWithRole", "args": { "Member": { "from": "Member", "where": { "Username": "scopeMember2" } }, "Role": { "from": "Role", "where": { "Name": "Role.Name'Project Lead" } }, "IsOwner": true } } } ``` :::warning Only the first match is used When a subquery supplies an operation argument, only the very first result becomes the value. If the subquery could match more than one asset, add a `sort` clause to make the choice deterministic. ::: This differs from a subquery used in a [relation update](/apis/asset-bulk/relation-updates#referencing-by-subquery), where **every** match is used. ## Undelete Deleted assets are excluded from queries by default, so `Undelete` must explicitly opt in with `IsDeleted: true`. ```yaml from: Story:1591 where: IsDeleted: true execute: Undelete ``` ## Deferring an Operation Until Processing An `execute` command that targets an asset created earlier in the same request must be wrapped in a user alias, so its projection is deferred until the processing phase — by which point the alias resolves to a real OID token. ```json [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "Story Test", "Children": [ { "@test1": { "AssetType": "Test", "Name": "My Test 1" } }, { "AssetType": "Test", "Name": "My Test 2" } ] } }, { "@inactivate": { "from": "@test1", "execute": "Inactivate" } }, { "from": "Test", "where": { "Parent": "@story" }, "select": ["Name", "AssetState"] } ] ``` See [Deferring commands](/apis/asset-bulk/aliases-and-projection#deferring-commands-until-the-processing-phase) for the full explanation. ## Unknown Operations Naming an operation the asset type does not support produces a command failure with HTTP `200`: ```json { "commands": [ { "@op": { "oid": "Scope:0", "execute": { "op": "NonexistentOperation", "args": {} } }, "error": { "message": "Unknown Operation 'Scope.NonexistentOperation'", "sourceCommandIndex": 0 } } ], "count": 1 } ``` ## Related Topics - [Operations](/concepts/operations) - [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection) - [Errors and partial failures](/apis/asset-bulk/errors) --- # Queries - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/queries - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/queries.md - Source: apis/asset-bulk/queries.md - Description: Issue one or many independent queries in a Bulk API request and read the multi-result-set response. - Tags: api, bulk, asset, query A query command is a command object with a `from` clause and no `update`, `execute`, or `AssetType`. Query results are returned in `queryResult`, separately from the create/update/operate counters. Query syntax is the same SQL-like grammar as [`query.v1`](/apis/query-v1): `from`, `select`, `where`, `filter`, `sort`, `page`, `asof`. ## `where` And `filter` There are two ways to narrow a query, and they are not interchangeable. | Clause | Form | Use it for | | --- | --- | --- | | `where` | An **object** of attribute/value pairs | Simple equality on one or more attributes — operators are not accepted here; use `filter`. Both clauses on one command combine as AND, but prefer one style per query | | `filter` | An **array** of filter-token strings | Operators, OR-lists, and anything `where` cannot express | ```json title="where — equality only" { "from": "Story", "where": { "Name": "Feature Story 1" } } ``` ```json title="filter — tokens" { "from": "Workitem", "filter": ["Owners.@Count>'0'"] } ``` A filter token is `Attribute` + operator + `'value'`. Repeated tokens are ANDed; comma-separated values inside one token are ORed: ```json { "from": "TeamRoom", "filter": ["Name='Team A'", "IsDeleted='True','False'"] } ``` ## Querying Deleted Assets Deleted assets are excluded automatically. The switch that includes them is `IsDeleted` **per command** — set it on each command that should see deleted assets. The `?deleted=1` URL parameter does **not** surface deleted rows on its own: a query command without an `IsDeleted` clause returns no deleted assets whether or not the flag is present. ### `IsDeleted` On the Command Say so per command, in whichever clause style you are using: ```json title="where form — boolean" { "from": "Story", "where": { "Name": "My Story", "IsDeleted": true } } ``` ```json title="filter form — string token" { "from": "TeamRoom", "filter": ["Name='Team A'", "IsDeleted='True'"] } ``` The OR-form `IsDeleted='True','False'` returns deleted **and** active assets in one result set. This is the conventional idiom for integrations that need a complete picture, and Core's own test suite refers to it as the standard filter used by `query.v1` and `api/asset` integrations. :::warning `IsDeleted` selects back as a string `"select": ["IsDeleted"]` returns `"True"` or `"False"` — the strings, not JSON booleans. The boolean `true` is only accepted on the *input* side, in a `where` object. ::: See also [Updating deleted assets](/apis/asset-bulk/updating-assets#updating-a-deleted-asset) and [Undelete](/apis/asset-bulk/operations#undelete), both of which need `IsDeleted` for the same reason. ## Paging and Totals A query command accepts `page` and `needTotal`: ```json { "from": "StoryStatus", "needTotal": true, "page": { "start": 0, "size": 5 } } ``` `page` is an object with `start` and `size` — unlike `rest-1.v1`, which takes a positional `page=size,start` query-string pair. See [the REST-1 API](/apis/rest-1) for that form. :::warning `total` is `null` on a paged query unless you ask for it Computing the total of a paged query is a second database round trip, so it is opt-in. Without `needTotal: true`, a **paged** query's `resultMetaData[n].total` is **`null`** — meaning "not computed", not a count of zero. An **unpaged** query's `total` is always a real count, because the server counts what it returns. Unlike `rest-1.v1`, this surface does not consult the `ApiDefaultNeedTotal` instance setting: without `needTotal` in the command, the count is simply not computed. Set `needTotal: true` whenever you depend on `total` on a paged query. `resultMetaData` itself is newer than the API: versions before 24.3 do not return it at all. ::: ## A Single Query ```json { "from": "Epic:1589", "select": [ "Name", { "from": "Subs", "select": [ "Name", { "from": "Subs", "select": ["Name", { "from": "Children", "select": ["Name"] }] } ] } ] } ``` ```json title="Response (abridged)" { "queryResult": { "results": [ [ { "_oid": "Epic:1589", "Name": "Epic with Subs", "Subs": [ { "_oid": "Epic:1590", "Name": "Feature Epic", "Subs": [ { "_oid": "Story:1591", "Name": "Feature Story 1", "Children": [ { "_oid": "Task:1596", "Name": "Task Time" }, { "_oid": "Test:1598", "Name": "Test Time" } ] } ] } ] } ] ], "count": 1 } } ``` `queryResult.count` is `1` because the request contained one query — **not** because one Epic matched. ## Result Shapes | Selection | Returned as | | --- | --- | | No `select` at all | `{ "_oid": "Story:1585" }` — the OID token only | | A scalar attribute | `"Name": "First Story"` | | A single-value relation | `"Parent": { "_oid": "Story:1591" }` | | A multi-value relation | `"Owners": [{ "_oid": "Member:20" }]` | | A dotted path | `"Scope.Name": "System (All Projects)"` | | A nested `from` / `select` | an **array**, keyed by the `from` value | Two consequences worth noting: - Every row always carries `_oid`, even when you did not select it. - A nested `from` on a **single-value** relation still returns an array of one. `{ "from": "RegressionSuite", "select": ["RegressionPlan"] }` yields `"RegressionSuite": [ { ... } ]`, whereas selecting `"RegressionSuite"` as a plain attribute would yield `{ "_oid": ... }`. ## Several Independent Queries Multiple queries in one request produce multiple result sets, in request order. ```yaml from: Story:1585 --- from: Story:1586 --- from: Story:1587 ``` ```json title="Response" { "queryResult": { "resultMetaData": [ { "page": { "start": 0, "size": 2147483647 }, "total": 1 }, { "page": { "start": 0, "size": 2147483647 }, "total": 1 }, { "page": { "start": 0, "size": 2147483647 }, "total": 1 } ], "results": [ [{ "_oid": "Story:1585" }], [{ "_oid": "Story:1586" }], [{ "_oid": "Story:1587" }] ], "count": 3 } } ``` Queries are genuinely independent — they need not share an asset type, a scope, or anything else: ```json [ { "from": "Workitem", "filter": ["Owners.@Count>'0'"], "select": ["Owners"] }, { "from": "Member:20", "select": ["Name", { "from": "OwnedWorkitems", "select": ["Name"] }] } ] ``` ### Empty Result Sets Are Preserved A query that matches nothing still occupies its slot, as an empty array with `total: 0` in `resultMetaData`. Positional correlation between your queries and `results` is therefore always safe. ```json title="Two queries, neither matching" { "queryResult": { "resultMetaData": [ { "page": { "start": 0, "size": 2147483647 }, "total": 0 }, { "page": { "start": 0, "size": 2147483647 }, "total": 0 } ], "results": [[], []], "count": 2 } } ``` ## Querying an Alias A query can use an alias defined earlier in the same request as its `from` target, which is how you retrieve server-generated values — a `Number`, a default `Status` — for assets you just created. ```json [ { "@story": { "AssetType": "Story", "Name": "New Story", "Scope": "Scope:0" } }, { "from": "@story", "select": ["Name", "Number"] } ] ``` An alias also works inside a `where` clause: ```json { "from": "Test", "where": { "Parent": "@story" }, "select": ["Name", "AssetState"] } ``` ## Query Ordering Within a Request Queries are evaluated in place, in request order, relative to the other commands. A query positioned before a deferred update or operation observes the pre-change state; the same query positioned after it observes the post-change state. Both can appear in the same request: ```json [ { "AssetType": "Story", "Name": "New Story", "Scope": "Scope:0", "Children": [{ "@task": { "AssetType": "Task", "Name": "Do it now" } }] }, { "from": "@task", "select": ["Name", "Parent"] }, { "@update": { "from": "@task", "update": { "Name": "It is done!" } } }, { "from": "@task", "select": ["Name", "Parent"] } ] ``` ```json title="Response queryResult.results" [ [{ "_oid": "Task:1490", "Name": "Do it now", "Parent": { "_oid": "Story:1489" } }], [{ "_oid": "Task:1490", "Name": "It is done!", "Parent": { "_oid": "Story:1489" } }] ] ``` ## Query Errors Query mistakes surface as command failures, not HTTP errors: | Mistake | `error.message` | | --- | --- | | Unknown asset type in `from` | `Unknown AssetType: Animal` | | Unknown attribute in `select` | `Unknown AttributeDefinition: Scope.MuddleBubble` | | Unknown attribute in `where` | `Invalid QueryFilter2 token MuddleBubble='BubbleMuddle'` | Note that the `where` failure reports a parser-level message rather than the attribute-definition message the `select` failure gives, even though the underlying mistake is the same. See [Errors and partial failures](/apis/asset-bulk/errors). ## Related Topics - [Query API (query.v1)](/apis/query-v1) - [Request and response format](/apis/asset-bulk/request-format) - [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection) --- # Projection, Processing and Aliases - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/aliases-and-projection - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/aliases-and-projection.md - Source: apis/asset-bulk/aliases-and-projection.md - Description: The two-phase execution model behind the Bulk API, auto-aliasing, user-aliasing, request hierarchy flattening, and deferred commands. - Tags: api, bulk, asset, aliases, concepts Every `api/asset` request is fulfilled in two phases. Almost everything that looks surprising about the Bulk API follows from this split. | Phase | What happens | | --- | --- | | **Projection** | The request is normalised into a flat, ordered list of single-purpose commands, each labelled with an alias. Hierarchies are flattened, shorthand syntaxes are expanded, and queries are left in place. Nothing is written. | | **Processing** | Each projected command is executed in order. Aliases are resolved to real OID tokens as the assets they name come into existence. | The result of the projection phase is a mapping of aliases to commands. You can inspect it directly with [preview mode](/apis/asset-bulk/preview-mode). ## Why the Split Matters - **Aliases**: a command can refer to an asset that does not exist yet, because resolution happens in the second phase. - **Flattening**: nested creates become sibling commands linked by `#ContextOid`, which is why a partial failure returns a flat list you can edit and resubmit. - **Deferral**: user-aliasing a command postpones its projection until processing, so it can see assets created earlier in the same request. - **Preview**: stopping after projection tells you exactly what will happen without writing anything. ## Auto-Aliasing If you do not name a command, the server names it for you. ```json title="Request" { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story" } ``` ```json title="Projection" [ { "@e630d6f8-5b95-44b7-8d30-3632549a0121$auto-aliased": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story" } } ] ``` Details worth noticing: - A single object is normalised into a one-element array. Each element is a wrapper object with exactly one property, whose value is the command. - The property name is a GUID. The `$auto-aliased` suffix marks it as server-generated rather than user-supplied. - The suffix appears **only on auto-aliased asset create commands**. An auto-aliased query or update gets a bare GUID with no suffix. ## User-Aliasing Wrap a command in a property whose name starts with `@` to name it yourself. ```json title="Request" { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story" } } ``` ```json title="Projection" [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story" } } ] ``` The alias is then usable anywhere a reference is accepted: | Position | Example | | --- | --- | | Relation value | `{ "AssetType": "Test", "Parent": "@story", "Name": "Test Time" }` | | Query `from` | `{ "from": "@story", "select": ["Number"] }` | | Query `where` | `{ "from": "Test", "where": { "Parent": "@story" } }` | | Update / execute `from` | `{ "@inactivate": { "from": "@test1", "execute": "Inactivate" } }` | | Operation argument | `{ "Member": "@memberAlias1", "Role": "Role:3" }` | :::tip The most common reason to user-alias Server-generated values — a Story `Number`, a Task `Number`, a default `Status` — are only knowable after creation. Alias the create, then query the alias in the same request: ```json [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story" } }, { "from": "@story", "select": ["Number"] } ] ``` ::: In YAML, quote the alias key so the `@` is not misread: ```yaml "@story": AssetType: Story Name: New Story Scope: Scope:0 --- from: "@story" select: - Name - Number ``` ## Request Hierarchy Flattening A nested create request is projected into a flat list. Each descendant gains a system-generated `#ContextOid` property pointing at its container's alias. `#ContextOid` is about **new-asset initialization**: the server seeds the new asset with the defaults it would receive if created from that context in the application, then applies your attributes on top. You can also supply it yourself on a flat create command — worth doing when the type has required relations that must be defaulted from a container rather than spelled out. ```json title="Request" { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story with Children", "Children": [ { "AssetType": "Task", "Name": "Task 1" }, { "AssetType": "Task", "Name": "Task 2" } ] } ``` ```json title="Projection" [ { "@0f4bcf21-0f0f-4668-a7ee-a4db84f593bb$auto-aliased": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story with Children" } }, { "@17afb273-11ae-4942-bf46-6fd14eda3472$auto-aliased": { "AssetType": "Task", "Name": "Task 1", "#ContextOid": "@0f4bcf21-0f0f-4668-a7ee-a4db84f593bb$auto-aliased" } }, { "@9d182825-e5d4-4bbc-99d4-591e8133e087$auto-aliased": { "AssetType": "Task", "Name": "Task 2", "#ContextOid": "@0f4bcf21-0f0f-4668-a7ee-a4db84f593bb$auto-aliased" } } ] ``` Observations: - The descendants keep their original relative order, immediately following their container. - During processing, `#ContextOid` is replaced with the container's real OID token, and becomes the context the contained asset is created in. - `assetsCreated.oidTokens` comes back in exactly this flattened order. ### Hierarchical User-Aliases User aliases can be supplied at any depth. The container may be auto-aliased while its children are user-aliased, or the reverse. ```json title="Request" { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story with Children", "Children": [ { "@task1": { "AssetType": "Task", "Name": "Task 1" } }, { "@task2": { "AssetType": "Task", "Name": "Task 2" } } ] } ``` ```json title="Projection" [ { "@8f49972e-e9ce-4465-8d6e-4dc91671d0fb$auto-aliased": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story with Children" } }, { "@task1": { "AssetType": "Task", "Name": "Task 1", "#ContextOid": "@8f49972e-e9ce-4465-8d6e-4dc91671d0fb$auto-aliased" } }, { "@task2": { "AssetType": "Task", "Name": "Task 2", "#ContextOid": "@8f49972e-e9ce-4465-8d6e-4dc91671d0fb$auto-aliased" } } ] ``` You can then query each Task by its alias. But note there is usually a simpler route — alias only the parent and use a nested select: ```json [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story with Children", "Children": [ { "AssetType": "Task", "Name": "Task 1" }, { "AssetType": "Task", "Name": "Task 2" } ] } }, { "from": "@story", "select": [{ "from": "Children:Task", "select": ["Number"] }] } ] ``` Two aliased queries return two result sets; one nested query returns one result set containing both Tasks. ## Deferring Commands Until the Processing Phase Projection normally happens up front, which is a problem for a command whose target does not exist yet. **User-aliasing an update or execute command defers its projection until the processing phase**, by which time earlier aliases have resolved to real assets. ```json [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "Story Test", "Children": [ { "@test1": { "AssetType": "Test", "Name": "My Test 1 (checked off immediately)" } }, { "AssetType": "Test", "Name": "My Test 2 (still undone)" } ] } }, { "@inactivate": { "from": "@test1", "execute": "Inactivate" } }, { "from": "Test", "where": { "Parent": "@story" }, "select": ["Name", "AssetState"] } ] ``` ```json title="Projection" [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "Story Test", "Children": [ { "@test1": { "AssetType": "Test", "Name": "My Test 1 (checked off immediately)" } }, { "AssetType": "Test", "Name": "My Test 2 (still undone)" } ] } }, { "@inactivate": { "from": "@test1", "execute": "Inactivate" } }, { "@147491a4-ce7f-4f1b-b238-626d6858d25b": { "from": "Test", "where": { "Parent": "@story" }, "select": ["Name", "AssetState"] } } ] ``` Observations: - The user-aliased Story is **not** flattened. Deferring it also defers the flattening of everything inside it — which is why `@test1` remains nested. - The only thing auto-aliased in this projection is the query. ```json title="Processing response" { "assetsCreated": { "oidTokens": ["Story:1582", "Test:1583", "Test:1584"], "count": 3 }, "assetsOperatedOn": { "oidTokens": ["Test:1583"], "count": 1 }, "queryResult": { "results": [ [ { "_oid": "Test:1583", "Name": "My Test 1 (checked off immediately)", "AssetState": "Closed" }, { "_oid": "Test:1584", "Name": "My Test 2 (still undone)", "AssetState": "Active" } ] ], "count": 1 } } ``` ### Queries Do Not Need Deferral Queries are always evaluated in place after the commands preceding them, so a user alias buys nothing. The following request contains the same query twice — once unaliased, once user-aliased — and produces two identical result sets: ```json [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story" } }, { "from": "@story", "select": ["Name", "Number"] }, { "@query": { "from": "@story", "select": ["Name", "Number"] } } ] ``` Do not reflexively wrap queries in aliases. ### Give Every Aliased Command Its Own Object Each aliased command must be **its own element in the array**. Aliases are not siblings inside one object, and getting this wrong fails in a way that depends on how many you have. ```json title="Correct — one object per command" [ { "@newEpic": { "AssetType": "Epic", "Name": "Parent", "Scope": "Scope:0" } }, { "@epic0": { "AssetType": "Epic", "Name": "Child A", "Super": "@newEpic", "Scope": "Scope:0" } }, { "@epic1": { "AssetType": "Epic", "Name": "Child B", "Super": "@newEpic", "Scope": "Scope:0" } } ] ``` ```json title="Wrong — aliases as siblings in one object" [ { "@newEpic": { "AssetType": "Epic", "Name": "Parent", "Scope": "Scope:0" }, "@epic0": { "AssetType": "Epic", "Name": "Child A", "Super": "@newEpic", "Scope": "Scope:0" }, "@epic1": { "AssetType": "Epic", "Name": "Child B", "Super": "@newEpic", "Scope": "Scope:0" } } ] ``` :::danger The failure mode changes with the number of siblings A command object is only recognised as aliased when it has **fewer than three children**. That threshold produces three different behaviours from the same mistake: | Aliases in one object | What happens | | ---: | --- | | 1 | Correct. This is the intended shape. | | 2 | **Accepted, and both are created.** It works — but only by falling under the threshold, so it is one added sibling away from failing. | | 3 or more | `200` with a `commandFailure` — `Invalid key at node(s): root->@a | root->@b | root->@c` — and **nothing is created**. | The middle row is the dangerous one. Two siblings work — the request succeeds and both assets are created — so the shape gets copied and scaled up. It fails only when a third command is added, and the failure then reads like a query syntax error rather than a payload-shape problem: with three or more children the object is no longer recognised as aliased, so it is read as a **query command**, and the query parser rejects the `@` keys it finds at the root. ::: **Nesting is usually the better answer anyway.** If the children belong to the parent, express that with a relation rather than three aliased commands: ```json [ { "AssetType": "Epic", "Name": "Parent", "Scope": "Scope:0", "Subs": [ { "AssetType": "Epic", "Name": "Child A", "Scope": "Scope:0" }, { "AssetType": "Epic", "Name": "Child B", "Scope": "Scope:0" } ] } ] ``` That avoids aliases entirely, and the hierarchy is flattened for you — see [request hierarchy flattening](#request-hierarchy-flattening). ## What Aliases Cannot Do \{#limitations\} Aliases are more capable than they first appear — a user alias can be referenced from a query's `from`, a `where` clause, an update or execute `from`, a relation value and an operation argument. That breadth makes the gaps surprising when you hit them, because the natural next step often turns out not to be supported. ### An Operation's Output Cannot Be Referenced **This is the limitation people hit most.** An alias names a *command*, and for a create command it resolves to the asset that command created. An operation that *produces* a new asset gives you nothing to name. The canonical case is copying an asset and then amending the copy: ```json title="Does not work" [ { "from": "Story:455628", "execute": "Copy" }, { "from": "", "update": { "Name": "New Updated Story" } } ] ``` There is no expression for the second `from`. Aliasing the execute command names the *command*, not the Story it produced, so it does not help either. Split it into two requests: run the `Copy`, read the new OID token from the response, then send the update. ### A Create Command Cannot Project Its Own Result A create returns OID tokens in `assetsCreated` and nothing else. To see any attribute of the asset you just made — a system-generated `Number`, a defaulted `Status` — add a **separate query command** referencing the create's alias: ```json [ { "@story": { "AssetType": "Story", "Scope": "Scope:0", "Name": "My Story" } }, { "from": "@story", "select": ["Number", "Status.Name"] } ] ``` That works. What is not available is asking the create command itself to return a projection, which would save a round of commands. ### A Subquery Cannot Supply a Single-Value Relation [Multi-value relations](/apis/asset-bulk/relation-updates#referencing-by-subquery) accept a subquery in place of a literal list, in both `add` and `remove`. A **single-value** relation does not — you cannot write a subquery as the value of `Scope`, `Parent` or `Status` in a create or an update. The practical workaround is a [moniker](/apis/asset-bulk/creating-assets#qualifying-a-name-with-a-path), which resolves a relation by name rather than requiring you to know its OID token: ```yaml AssetType: Story Name: My Story Scope: Offerings\Agility 25.2\Optimize ``` That covers the common case of "the asset I mean is identified by its name". It does not cover choosing a single-value relation by an arbitrary filter. No test asserts a *negative*, so treat these limitations as accurate descriptions of current behaviour rather than as guarantees about future releases. ## Related Topics - [Preview mode](/apis/asset-bulk/preview-mode) - [Errors and partial failures](/apis/asset-bulk/errors) - [Compound requests](/apis/asset-bulk/compound-requests) - [Queries](/apis/asset-bulk/queries) --- # Preview Mode - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/preview-mode - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/preview-mode.md - Source: apis/asset-bulk/preview-mode.md - Description: Use ?previewOnly=true to stop a Bulk API request after the projection phase and inspect exactly what it would do. - Tags: api, bulk, asset, preview Adding `?previewOnly=true` stops the request after the [projection phase](/apis/asset-bulk/aliases-and-projection). Nothing is written. The response is the projection itself — the flat, aliased list of commands the server would have executed. ```text POST ~/api/asset?previewOnly=true ``` ## What You Get Back The response is **not** the usual envelope. It is a JSON array of aliased commands. ```json title="Request" { "AssetType": "Story", "Scope": "Scope:0", "Name": "Story Time", "Children": [{ "AssetType": "Test", "Name": "Test Time" }] } ``` ```json title="Response" [ { "@ffa2169f-bd6b-44d2-9cca-05c37a725dfd$auto-aliased": { "AssetType": "Story", "Scope": "Scope:0", "Name": "Story Time" } }, { "@31e11be0-525c-4ee1-9002-4e6ee4453641$auto-aliased": { "AssetType": "Test", "Name": "Test Time", "#ContextOid": "@ffa2169f-bd6b-44d2-9cca-05c37a725dfd$auto-aliased" } } ] ``` ## The Projection Round-Trips The projection is itself a valid request body. Posting it back to `api/asset` without `?previewOnly=true` produces the same result as posting your original payload would have. This works because the projected content is already user-aliased from the server's point of view, so the second request forwards each command through to processing essentially unchanged. That makes preview mode a **review gate** for risky bulk edits: 1. Post the request with `?previewOnly=true`. 2. Read the projection and confirm it targets what you expect. 3. Delete or edit any commands you do not want. 4. Post the edited array back to `api/asset`. :::tip This is the answer to unbounded query-scoped updates A command like `{ "from": "Story", "update": { "Status": "Done" } }` matches every Story you can see. Previewing it expands the query into one explicit per-asset command, so you can count them, inspect them, and trim the list before committing. See [Updating assets](/apis/asset-bulk/updating-assets#updating-everything-that-matches-a-query). Preview is what lets you confirm the blast radius before it commits: the expanded list is either the size you expected or it is thousands of rows, and you see which before anything is written. The same applies to a mass `execute` of `Close` or `Delete` — and unlike updates, those [cannot carry a `ChangeComment`](/apis/asset-bulk/request-format#processing-model-large-batches-run-to-completion), so previewing is the guard rail you have before the fact. ::: ## What the Projection Reveals Preview is the only way to observe several behaviors directly: | Behavior | What you see | | --- | --- | | [Auto-aliasing](/apis/asset-bulk/aliases-and-projection#auto-aliasing) | GUID aliases, with `$auto-aliased` on create commands | | [Hierarchy flattening](/apis/asset-bulk/aliases-and-projection#request-hierarchy-flattening) | Nested creates become siblings linked by `#ContextOid` | | [Update normalisation](/apis/asset-bulk/updating-assets#scalar-and-single-value-relation-attributes) | Every attribute update becomes `{ "add": [...], "remove": [] }` | | [Query expansion](/apis/asset-bulk/updating-assets#updating-everything-that-matches-a-query) | A `where`-scoped update becomes one command per matched asset, each with a literal `oid` | | [Deferral](/apis/asset-bulk/aliases-and-projection#deferring-commands-until-the-processing-phase) | User-aliased commands stay unflattened and unexpanded | | [Find and replace](/apis/asset-bulk/find-and-replace#on-multi-value-relations) | `find` / `replace` is preserved rather than resolved to `add` / `remove` | ## Caveats :::warning Preview mode is not covered by tests The `previewOnly` parameter, the response shape and the resubmit round trip described on this page are not asserted by any executable test. Treat the exact projection formats as illustrative, and verify behaviour against your instance before relying on preview ahead of a destructive change. ::: Preview evaluates queries in order to expand them, so a preview of a query-scoped update still costs a read against the database. It is not free, just non-destructive. ## Related Topics - [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection) - [Updating assets](/apis/asset-bulk/updating-assets) - [Errors and partial failures](/apis/asset-bulk/errors) --- # Errors and Partial Failures - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/errors - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/errors.md - Source: apis/asset-bulk/errors.md - Description: How the Bulk API reports partial failure, how failures cascade through a hierarchy, and how to correct and reissue a failed request safely. - Tags: api, bulk, asset, errors The Bulk API is not transactional. A request that partly succeeds returns HTTP `200 OK` with the successes applied and the failures itemised in `commandFailures`. :::warning Always check `commandFailures.count` A `200` status tells you the request was understood, not that it worked. Check the failure count on every response. ::: ## Before the Failure Model Applies: Unparseable Requests The graceful partial-failure model below covers commands the server **understood**. A request it cannot parse never reaches that stage, and fails in a much less helpful way. :::danger Malformed JSON returns an opaque 500, not a 400 Send a body that is not valid JSON and you get: ```json title="HTTP 500" { "Exceptions": ["Server Error", "Reference: 76894036-5f86-49fd-bb4f-58970c644e2c"] } ``` There is **no indication that the problem was your JSON**. No parse error, no line or character position, no `400`. The response is indistinguishable from a genuine server fault, and the reference GUID is only useful to someone with access to the host's exception log — which on a hosted instance is nobody outside Digital.ai. A missing body behaves the same way — see [request format](/apis/asset-bulk/request-format). ::: ### What to Do About It Because the server will not tell you, the diagnosis has to happen on your side: - **Validate the JSON before sending it.** Any parser will do. This is worth doing in the client rather than debugging against the API. - **Treat a `500` with no `commandFailures` as "probably my payload".** A real command failure comes back as `200` with an itemised `commandFailures` entry. If you get a `500`, the request almost certainly never parsed. - **Suspect the shape, not just the syntax.** Structurally valid JSON in the wrong shape fails the same way — the [sibling-alias trap](/apis/asset-bulk/aliases-and-projection#limitations) is a `500` from perfectly well-formed JSON. - **Keep the reference GUID** if you need to raise a support case. It is the only handle on the underlying error, and on a hosted instance it is the *only* thing you can do with it. On an **on-premise** instance the matching entry is written to an exception log on the host, recording the real exception type and message in full. That is only reachable if you have access to the host's file store — which the person writing the integration frequently does not. Treat it as a route worth asking an administrator about, not one you can rely on. :::note Worth knowing when comparing surfaces Other Agility endpoints are stricter and more informative about payload problems. [`api/webhook`](/apis/webhooks/create) rejects unknown keys with a `400` and a message; [`api/localizations`](/apis/loc-2-v1/overrides) names the offending key. The Bulk API's tolerance of unknown keys is convenient, but it comes with a correspondingly blunt response when the body is wrong. ::: A truncated object and an entirely non-JSON body both return `500` with the two-element `Exceptions` array shown above. ## The Failure Entry Shape Each entry in `commandFailures.commands` is a **complete, resubmittable command** with an extra `error` property. ```json { "commandFailures": { "commands": [ { "@06b3bf9a-8906-4376-9bf6-672ced28b350$auto-aliased": { "AssetType": "Test", "Parent": "Stor:1591", "Name": "Test Time" }, "error": { "message": "Resolution'Unresolved'Test.Parent'Workitem", "sourceCommandIndex": 1 } } ], "count": 1 } } ``` | Property | Meaning | | --- | --- | | `` | The projected command, keyed by its user or auto alias | | `error.message` | The failure reason | | `error.sourceCommandIndex` | Zero-based index of the command **in your original payload** that produced this failure | | `error.stackTrace` | **Development builds only — never depend on it.** See below | :::warning `stackTrace` never appears on customer deployments `error.stackTrace` is not part of the contract. Release builds — which is what every customer deployment runs, hosted and on-premise alike — never include stack traces or exception detail in error responses. This is not a configuration default: no setting an administrator can reach turns it on. The field exists only on **development builds**, which are internal to Digital.ai — no customer or partner deployment ships one. That is why an example payload captured on an internal development instance may include the field, and why the property is worth documenting at all. On any deployment you can reach as a customer or partner, it is never present. On-premise administrators who need stack traces for diagnosis have them in the server-side exception logs; that detail is never returned to API callers. The practical consequence: an example or test captured against an internal development instance can mislead about what any customer deployment returns, with **no API change and no version difference**. Strip the field before comparing responses, and never parse it or assert on it. ::: Because the entry echoes the **projected** command rather than your original text, an update failure comes back in normalised `oid` + `add`/`remove` form: ```json { "@update": { "oid": "Scope:0", "update": { "MuddleBubble": { "add": ["BubbleMuddle"], "remove": [] } } }, "error": { "message": "Unknown AttributeDefinition: Scope.MuddleBubble", "sourceCommandIndex": 0 } } ``` and an execute failure comes back in `op` + `args` form: ```json { "@op": { "oid": "Scope:0", "execute": { "op": "NonexistentOperation", "args": {} } }, "error": { "message": "Unknown Operation 'Scope.NonexistentOperation'", "sourceCommandIndex": 0 } } ``` ## Cascading Failures in a Hierarchy When a create command fails, every descendant fails too — because each descendant's `#ContextOid` points at an alias that never resolved. ```yaml title="Request — the Scope does not exist" AssetType: Epic Scope: Scope:9999 Name: My Epic on a Scope that DOES NOT EXIST Subs: - AssetType: Story Name: My Story Children: - AssetType: Test Name: My Test - AssetType: Task Name: My Task ``` One mistake produces **four** failures: ```json title="commandFailures" { "commands": [ { "@epic": { "AssetType": "Epic", "Scope": "Scope:9999", "Name": "My Epic on a Scope that DOES NOT EXIST" }, "error": { "message": "Violation'Invalid'Epic.Scope", "sourceCommandIndex": 0 } }, { "@story": { "AssetType": "Story", "Name": "My Story", "#ContextOid": "@epic" }, "error": { "message": "Invalid OID token: @epic", "sourceCommandIndex": 0 } }, { "@test": { "AssetType": "Test", "Name": "My Test", "#ContextOid": "@story" }, "error": { "message": "Invalid OID token: @story", "sourceCommandIndex": 0 } }, { "@task": { "AssetType": "Task", "Name": "My Task", "#ContextOid": "@story" }, "error": { "message": "Invalid OID token: @story", "sourceCommandIndex": 0 } } ], "count": 4 } ``` Read it as follows: - The **root cause** is the first entry. The other three are consequences. - All four share `sourceCommandIndex: 0`, because the whole hierarchy came from a single top-level command in the original payload. `sourceCommandIndex` identifies the command you sent, not the projected command. - The `#ContextOid` chain is intact, so the shape of the original hierarchy survives in the failure list. ## Reissuing a Failed Request Because failure entries are complete commands, the recovery loop is mechanical: **fix the value, resubmit the array**. :::warning Resubmit echoed commands with their alias wrapper intact An echoed update is addressed by `oid`, and that form is only accepted **inside an alias wrapper** (`{ "@name": { "oid": …, "update": … } }`) — the shape the failure entry already has. Strip the wrapper and the bare `{ "oid": …, "update": … }` object is read as a query and rejected with `Invalid key at node(s): root->oid`. Echoed **operation** failures do not resubmit cleanly in either form on current versions — rewrite them as `from`-addressed `execute` commands instead. ::: Given a partially failed request: ```json title="Original request — one misspelled OID token" [ { "AssetType": "Task", "Parent": "Story:1591", "Name": "Task Time" }, { "AssetType": "Test", "Parent": "Stor:1591", "Name": "Test Time" } ] ``` ```json title="Response — one created, one failed" { "assetsCreated": { "oidTokens": ["Task:1596"], "count": 1 }, "commandFailures": { "commands": [ { "@06b3bf9a-8906-4376-9bf6-672ced28b350$auto-aliased": { "AssetType": "Test", "Parent": "Stor:1591", "Name": "Test Time" }, "error": { "message": "Resolution'Unresolved'Test.Parent'Workitem", "sourceCommandIndex": 1 } } ], "count": 1 } } ``` Take `commandFailures.commands` verbatim, correct `Stor:1591` to `Story:1591`, and post it back: ```json title="Reissue" [ { "@06b3bf9a-8906-4376-9bf6-672ced28b350$auto-aliased": { "AssetType": "Test", "Parent": "Story:1591", "Name": "Test Time" }, "error": { "message": "Resolution'Unresolved'Test.Parent'Workitem", "sourceCommandIndex": 1 } } ] ``` Three things make this safe: - You do **not** have to strip the `error` property. The server ignores it. - You do **not** have to strip the server-generated aliases. They are treated as user aliases on the way back in, so `#ContextOid` references between entries still line up. - The successful commands are absent from the list, so nothing is applied twice. :::warning Idempotency is your responsibility Reissuing the failure list is safe. Reissuing the **original** payload is not — the commands that already succeeded would run again, creating duplicates. ::: ## Error Message Catalogue | `error.message` | Cause | | --- | --- | | `Unknown AssetType: Animal` | `from` names an asset type that does not exist | | `Unknown AttributeDefinition: Scope.MuddleBubble` | `select` or `update` names an attribute the type does not have | | `Invalid QueryFilter2 token MuddleBubble='BubbleMuddle'` | `where` names an unknown attribute | | `Unknown Operation 'Scope.NonexistentOperation'` | `execute` names an operation the type does not support | | `Violation'Invalid'Epic.Scope` | A relation points at a non-existent asset | | `Invalid OID token: @` | An alias never resolved, because the command that would have created it failed | | `Resolution'Unresolved'Test.Parent'Workitem` | A relation value given by name matched no asset — see [monikers](/apis/asset-bulk/creating-assets#moniker-must-be-unique) | | `Resolution'Ambiguous'Story.Timebox'Timebox` | A relation value given by name matched more than one asset — same page | Note the inconsistency worth planning around: an unknown attribute in `select` reports `Unknown AttributeDefinition`, while the same mistake in `where` reports a `QueryFilter2` parser message. Do not match on message text alone if you can avoid it. The `Violation'...'` and `Resolution'...'` forms are Agility's apostrophe- delimited message tokens, shared with other endpoints. See [Error handling](/guides/error-handling). ## Related Topics - [Preview mode](/apis/asset-bulk/preview-mode) — catch these before they happen - [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection) - [Error handling](/guides/error-handling) - [HTTP status codes](/reference/status-codes) --- # Compound Requests - URL: https://docs.digital.ai/agility-api/apis/asset-bulk/compound-requests - Markdown: https://docs.digital.ai/agility-api/apis/asset-bulk/compound-requests.md - Source: apis/asset-bulk/compound-requests.md - Description: A capstone Bulk API example that creates a project hierarchy, invokes an operation on a newly created asset, and queries the result in one request. - Tags: api, bulk, asset, examples The features on the preceding pages compose. This page works through the suite's capstone example, which creates six assets across five asset types, invokes an operation on one of them, and queries the outcome — all in a single round trip. ## Scenario Automate regression-test setup end to end: create a Scope, a Regression Plan, a Regression Suite and two Regression Tests, then create a Test Set bound to the Suite, run `CopyAcceptanceTestsFromRegressionSuite` to generate Tests from the Regression Tests, and read back what was produced. ## The Request ```yaml title="YAML" "@scope": AssetType: Scope Name: Scope for Regression Test Parent: Scope:0 BeginDate: 12/31/2019 --- "@plan": AssetType: RegressionPlan Name: Plan Scope: "@scope" --- "@suite": AssetType: RegressionSuite RegressionPlan: "@plan" Name: Regression Suite --- AssetType: RegressionTest Name: Regression Test 1 Scope: "@scope" RegressionSuites: "@suite" --- AssetType: RegressionTest Name: Regression Test 2 Scope: "@scope" RegressionSuites: "@suite" --- "@test-set": AssetType: TestSet Name: New TestSet Scope: "@scope" RegressionSuite: "@suite" --- "@copy": from: "@test-set" execute: CopyAcceptanceTestsFromRegressionSuite --- from: "@test-set" select: - Name - Scope - from: RegressionSuite select: - RegressionPlan - from: Children:Test select: - Name - GeneratedFrom - Status ``` ```json title="JSON" [ { "@scope": { "AssetType": "Scope", "Name": "Scope for Regression Test", "Parent": "Scope:0", "BeginDate": "12/31/2019" } }, { "@plan": { "AssetType": "RegressionPlan", "Name": "Plan", "Scope": "@scope" } }, { "@suite": { "AssetType": "RegressionSuite", "RegressionPlan": "@plan", "Name": "Regression Suite" } }, { "AssetType": "RegressionTest", "Name": "Regression Test 1", "Scope": "@scope", "RegressionSuites": "@suite" }, { "AssetType": "RegressionTest", "Name": "Regression Test 2", "Scope": "@scope", "RegressionSuites": "@suite" }, { "@test-set": { "AssetType": "TestSet", "Name": "New TestSet", "Scope": "@scope", "RegressionSuite": "@suite" } }, { "@copy": { "from": "@test-set", "execute": "CopyAcceptanceTestsFromRegressionSuite" } }, { "from": "@test-set", "select": [ "Name", "Scope", { "from": "RegressionSuite", "select": ["RegressionPlan"] }, { "from": "Children:Test", "select": ["Name", "GeneratedFrom", "Status"] } ] } ] ``` ## The Response ```json { "assetsCreated": { "oidTokens": [ "Scope:1533", "RegressionPlan:1534", "RegressionSuite:1535", "RegressionTest:1536", "RegressionTest:1537", "TestSet:1538" ], "count": 6 }, "assetsModified": { "oidTokens": [], "count": 0 }, "assetsOperatedOn": { "oidTokens": ["TestSet:1538"], "count": 1 }, "commandFailures": { "commands": [], "count": 0 }, "queryResult": { "results": [ [ { "_oid": "TestSet:1538", "Name": "New TestSet", "Children:Test": [ { "_oid": "Test:1539", "Name": "Regression Test 1", "GeneratedFrom": { "_oid": "RegressionTest:1536" }, "Status": { "_oid": "NULL" } }, { "_oid": "Test:1540", "Name": "Regression Test 2", "GeneratedFrom": { "_oid": "RegressionTest:1537" }, "Status": { "_oid": "NULL" } } ] } ] ], "count": 1 } } ``` ## What to Notice - **Aliases chain.** `@plan` references `@scope`, `@suite` references `@plan`, and the Regression Tests and Test Set reference both. None of these assets exists when the request is sent. - **Mixed aliasing is fine.** The two Regression Tests are left unaliased and are auto-aliased by the server; nothing else in the request needs to refer back to them. - **`@copy` must be user-aliased.** The operation targets `@test-set`, which does not exist at projection time. Aliasing the execute command [defers](/apis/asset-bulk/aliases-and-projection#deferring-commands-until-the-processing-phase) it to the processing phase. - **The query needs no alias.** Queries are already evaluated after the commands preceding them. - **`TestSet:1538` appears in both `assetsCreated` and `assetsOperatedOn`,** because the request both created it and invoked an operation on it. - **Assets created by the operation are not in `assetsCreated`.** The two generated Tests (`Test:1539`, `Test:1540`) appear only in the query result. `assetsCreated` reports assets created by *your* create commands, not side effects of an operation. - **`Status` comes back as `{ "_oid": "NULL" }`,** not `null` — an unset single-value relation is an object with the sentinel OID token `NULL`. - **The downcast key is preserved.** `Children:Test` is both the `from` value and the response key. :::note An unset single-value relation selects back as `{ "_oid": "NULL" }` — a string sentinel, not JSON `null`. ::: ## A Smaller Variant If the Regression Suite and its Regression Tests already exist, the request collapses to three commands: ```json [ { "@test-set": { "AssetType": "TestSet", "Name": "Tests", "Scope": "Scope:1503", "RegressionSuite": "RegressionSuite:1505" } }, { "@copy": { "from": "@test-set", "execute": "CopyAcceptanceTestsFromRegressionSuite" } }, { "from": "@test-set", "select": [ "Name", { "from": "Children:Test", "select": ["Name", "GeneratedFrom", "Status"] } ] } ] ``` This is the same pattern — create, defer an operation onto the created asset, read back — and it is the shape most system-to-system synchronisation code ends up with. ## Related Topics - [Projection, processing and aliases](/apis/asset-bulk/aliases-and-projection) - [Invoking operations](/apis/asset-bulk/operations) - [Queries](/apis/asset-bulk/queries) - [cURL examples](/examples/curl) --- # Meta API (meta.v1) - URL: https://docs.digital.ai/agility-api/apis/meta-v1 - Markdown: https://docs.digital.ai/agility-api/apis/meta-v1.md - Source: apis/meta-v1/index.md - Description: Discover the asset types, attribute definitions and operations your Agility instance actually supports, before you address them through rest-1.v1. - Tags: api, meta, metadata, schema, discovery ## Overview `meta.v1` describes the schema of your Agility instance: which asset types exist, what attributes each one has, how they relate, and which operations can be executed against them. It exists because [`rest-1.v1`](/apis/rest-1) is schema-driven. Asset types and attributes are not a fixed list — they vary by version, by edition, and by customisation. Rather than hard-coding a schema into your integration, read it from `meta.v1` at runtime. The API is read-only. `GET` is the only method it accepts. ## Prerequisites - A running Agility instance and its application path, for example `http://localhost/VersionOne`. - Nothing else. `meta.v1` requires **no authentication of any kind** — unlike every other Agility API. :::warning[No authentication means no permission filtering] Because `meta.v1` is unauthenticated, it describes the schema of the instance as a whole, not the subset a particular member can see. Anyone who can reach the endpoint can enumerate your asset types and attribute names. Treat the schema as public information, and do not rely on `meta.v1` to tell you what a given member is allowed to read — that is `rest-1.v1`'s job. The in-product reference also documents a `401 Unauthorized` response for `meta.v1`, which contradicts the "no authentication" statement in the same document. In practice authentication is not required; the `401` documentation appears to be inherited boilerplate. ::: ## The URL Shape Four request shapes, from broadest to narrowest: | URL | Returns | | ---------------------------- | ------------------------------------------------------ | | `/meta.v1/` | The entire schema in one response. | | `/meta.v1/{AssetType}` | One asset type, with its attribute definitions and operations. | | `/meta.v1/{AssetType}/{AttributeName}` | One attribute definition. | | `/meta.v1/{AssetType}/{OperationName}` | One operation. | Attribute definitions and operations share the same URL slot, because their names do not collide within an asset type. ```bash title="What does a Story look like?" curl "${AGILITY_BASE_URL}/meta.v1/Story" ``` The response is XML by default: a `Meta` or `AssetType` root containing `AttributeDefinition` elements, `Operation` elements, and reference elements such as `DefaultOrderBy` and `DefaultDisplayBy` that suggest how the asset type is normally sorted and labelled. **JSON is available too** — send `Accept: application/json`, or put it in the query string, which is handy in a browser: ```bash curl -H "Accept: application/json" "${AGILITY_BASE_URL}/meta.v1/Story" curl "${AGILITY_BASE_URL}/meta.v1/Story?accept=application/json" ``` Either way, every element carries an `href` you can follow and a `token` you can use in a `rest-1.v1` URL, so you can walk from the schema straight into the data. :::tip[Fetch narrowly] `GET /meta.v1/` returns the *entire* schema, which is large. Fetch a single asset type when that is all you need, and cache what you fetch — the schema only changes when the instance is upgraded or customised. ::: ## What an Attribute Definition Tells You Each `AttributeDefinition` reports enough for an integration to handle the attribute generically: | Property | Meaning | | ------------------ | ------------------------------------------------------------- | | `name`, `token` | How to address the attribute in a `rest-1.v1` URL. | | `attributetype` | The value's data type. See [Attribute types](/reference/attribute-types). | | `isrequired` | Whether a value must be supplied when creating an asset. | | `isreadonly` | Whether the attribute can be written at all. | | `ismultivalue` | Whether the attribute holds one value or a collection. | | `RelatedAsset` | For relations, the asset type on the other end. | | `ReciprocalRelation` | The attribute that points back the other way. | Together these are what let a generic client build a form, validate input, or decide whether `act="set"` or `act="add"` is the right update verb. Operations are simpler: each `Operation` element carries a name and a `Validator` reference to the attribute definition that determines whether the operation is currently valid for a given asset. ## Typical Use 1. `GET /meta.v1/{AssetType}` once at start-up and cache it. 2. Use `isrequired` to work out the minimum payload for creating an asset. 3. Use `attributetype` and `ismultivalue` to serialise values correctly. 4. Use the operation list to decide which state transitions to offer, and the validator attribute to decide whether to enable each one. 5. Address the data through [`rest-1.v1`](/apis/rest-1) using the tokens `meta.v1` gave you. ## Related Topics - [Query examples](/apis/meta-v1/query-examples) — all four request shapes with real responses. - [Security & errors](/apis/meta-v1/security-and-errors) — the authentication position and error payload format. - [Attribute types](/reference/attribute-types) — the authoritative list of `attributetype` values. - [REST API (rest-1.v1)](/apis/rest-1) — addressing the data this schema describes. - [Information model](/apis/rest-1/information-model) — how asset types, attribute definitions, assets and attributes fit together. --- # Query Examples - URL: https://docs.digital.ai/agility-api/apis/meta-v1/query-examples - Markdown: https://docs.digital.ai/agility-api/apis/meta-v1/query-examples.md - Source: apis/meta-v1/query-examples.md - Description: The four meta.v1 query shapes - all of Meta, one asset type, one attribute definition and one operation - with real responses. - Tags: meta, metadata, schema, examples There are 4 ways to retrieve information from the Meta API. You can retrieve all of Meta in one large response, you can retrieve a specific asset type's information, or you can retrieve information for an attribute definition or an operation. ## How to Retrieve All of Meta {#ex-1} The following example will retrieve the entire Meta description in a single large XML response. `~/meta.v1/` ```xml ``` :::note[Remarks] The root element is called "Meta" and contains an href attribute which indicates what URL was used to retrieve the information. The "Meta" element will contain a number of sub elements called "AssetType" which correspond to each of asset types supported by the Agility API. In each "AssetType" element are multiple "AttributeDefinition" elements and multiple "Operation" elements as well as a few other nodes that are attribute definition references which provide suggestions on how to sort or display certain asset types. Inside each "AttributeDefinition" node there are a number of relevant nodes that describe the specific attribute definition. Finally inside each "Operation" node is a "Validator" node which is a reference to an attribute definition that indicates whether the operation is valid for a given asset. ::: ## How to Retrieve Meta for a Story Asset Type {#ex-2} The following example will retrieve the Meta description for the Story asset type. `~/meta.v1/Story` ```xml ``` :::note[Remarks] When asking for a specific asset type, such as the one above, the response will be very similar to what is displayed when asking for all of Meta. Note how the asset type contains a "name" attribute as well as an attribute called "abstract". The "abstract" attribute indicates that the Story asset type is a concrete type. A Workitem asset type on the other hand is an abstract type because other asset types derive from it. You'll also notice that one of the sub-nodes under the "AssetType" element is called "Base". This is an asset type reference node that indicates which asset type Story derives from. In the above example the Story asset type derives from a type called PrimaryWorkitem. ::: ## How to Retrieve Meta for the Name Attribute on the Story Asset Type {#ex-3} The following example will retrieve the Meta description for the Name attribute. `~/meta.v1/Story/Name` ```xml ``` :::note[Remarks] The root element is called "AttributeDefinition". It contains an "of" attribute which indicates the URL of the asset type that the attribute belongs to. It also contains a "name" attribute and a "token" attribute. When an attribute definition reference is used, it will contain either an attribute called "tokenref" or "nameref" which corresponds directly to an attribute definitions "token" and "name" attributes respectfully. The "displayname" attribute is an unlocalized string that can be passed to the [Localization API](/apis/loc-2-v1) to retrieve a meaningful name for the attribute definition. The "attributetype" attribute indicates what type of data this attribute definition represents. See the [List of Attribute Types](/reference/attribute-types#appendix-table-attributetypes) for more information. Finally the node contains an "isreadonly" attribute which indicates that the attribute is read only and cannot be updated or set, an "isrequired" attribute which indicates that the attribute is required to save or update an asset of the specific asset type, and finally an "ismultivalue" attribute which indicates that there may be multiple values for the attribute definition. Currently only attribute definitions of type "Relation" may be multi-value attribute definitions. ::: ## How to Retrieve Meta for the Delete Operation on the Story Asset Type {#ex-4} The following example will retrieve the Meta description for the Delete operation. `~/meta.v1/Story/Delete` ```xml ``` :::note[Remarks] The root element is called "Operation". It contains an "of" attribute which indicates the URL of the asset type that the attribute belongs to. The operation may contain a sub element called "Validator" which is a reference to an attribute definition. A validator is the name of an attribute that can be asked for on an asset to determine whether the operation is valid for the asset. So for example, you can query a Story asset with ID 1000 for the IsDeletable attribute to determine whether you can call the Delete operation on the asset. ::: --- # Security and Errors - URL: https://docs.digital.ai/agility-api/apis/meta-v1/security-and-errors - Markdown: https://docs.digital.ai/agility-api/apis/meta-v1/security-and-errors.md - Source: apis/meta-v1/security-and-errors.md - Description: meta.v1 requires no authentication. This page covers that, and the error payload and status codes it returns. - Tags: meta, authentication, errors, status-codes ## Security {#security} Unlike the other Agility APIs, the Meta API does not require authentication of any kind. ## Errors {#errors} When a request results in an error, the response will have an HTTP status code in the range 400-500, and detailed information about the error will be returned in the response payload. The response is an Error element, with an href attribute indicating the offending requested URL. The Message element contains a textual description of the error. Optional Exception elements may contain technical details about the cause of the error. ### Common Error Status Codes {#error-codes} #### 401 Unauthorized {#error-401} A 401 status code indicates that the request could not be authenticated. This may be caused by any one of these conditions: - No credentials supplied to Basic Authentication, and no Authorization Ticket cookie sent with the request. - An invalid username or password was supplied to Basic Authentication. - An expired or invalid Authorization Ticket cookie sent with the request. The typical course of action would be to include Basic Authorization credentials and resend the request. #### 404 Invalid URL {#error-404} A 404 status code indicates that there is no resource to retrieve at the requested URL. This error is usually caused by an invalid path. #### 405 Method Not Allowed {#error-405} A 405 status code indicates that the HTTP method used for the request is not valid at the requested URL. This will be returned for any method other than GET. #### 500 Internal Server Error {#error-500} A 500 status code indicates that an unexpected error occurred during the processing of the request. The response payload will contain Exception elements with specific information that will help technical diagnose the cause of the error. This could be due to: - An environmental problem (e.g., the meta database is unavailable). - An unhandled exception within the Agility software. --- # REST API (rest-1.v1) - URL: https://docs.digital.ai/agility-api/apis/rest-1 - Markdown: https://docs.digital.ai/agility-api/apis/rest-1.md - Source: apis/rest-1/index.md - Description: Query, create, update and operate on Agility assets over HTTP, including full historical access through the Hist namespace. - Tags: api, rest, create, update, crud, history ## Overview `rest-1.v1` is the general-purpose data API. Every asset, every attribute and every past version of both is addressable by URL. `GET` reads, `POST` writes. It is a data API, not a task API: rather than a fixed catalogue of endpoints, it exposes one URL shape that is parameterised by asset type, so it grows automatically as the schema does. Ask the [Meta API](/apis/meta-v1) what asset types and attributes exist, then address them here. ## Prerequisites - A running Agility instance and its application path, for example `http://localhost/VersionOne`. - Credentials. Unlike [`meta.v1`](/apis/meta-v1), `rest-1.v1` **requires authentication**, and every request is subject to the calling member's permissions. See [Security & errors](/apis/rest-1/security-and-errors). - A working understanding of [assets, attributes and moments](/apis/rest-1/information-model). ## The URL Shape All requests are made against a single template: ```text ~/rest-1.v1/{QueryType}/{AssetType}/{AssetID}/{Moment}/{AttributeName}?{QueryString} ``` | Segment | Required | Meaning | | --------------- | -------- | ----------------------------------------------------------------- | | `QueryType` | yes | `Data`, `Hist` or `New` — see below. | | `AssetType` | yes | The asset type token, for example `Story`, `Defect`, `Member`. | | `AssetID` | no | Integer identifying one asset. Omit it to address the whole list. | | `Moment` | no | Integer identifying one past version of that asset. | | `AttributeName` | no | An attribute token, to address a single attribute rather than the whole asset. | | `QueryString` | no | Selection, filtering, sorting, paging, search and operations. | Each segment narrows the result. `/Data/Story` is every Story you can see; `/Data/Story/1364` is one Story; `/Data/Story/1364/903` is that Story as it was at moment 903; `/Data/Story/1364/903/Name` is a single attribute of that version. ### The Three Namespaces | `QueryType` | Purpose | Response root | | ----------- | -------------------------------------------------------------------- | ------------- | | `Data` | Current data. Query with `GET`, create and update with `POST`. | `Assets` | | `Hist` | Historical data. Every version of every matching asset, read-only. | `History` | | `New` | A template for a new asset, pre-populated with defaults and rules. | `Asset` | `New` is worth knowing about: rather than guessing which attributes a new asset needs, `GET /rest-1.v1/New/Story?ctx=Scope:1000` returns a skeleton asset already filled in for that context. See [New asset templates](/apis/rest-1/new-asset-templates). ### Query String Parameters | Parameter | Purpose | | ------------ | ----------------------------------------------------------------------- | | `sel` | Which attributes to return. | | `select` | Undocumented alias for `sel`. **Takes precedence when both are sent.** | | `where` | Filter a list of assets. | | `with` | Bind variables referenced by `where`. | | `sort` | Order a list of assets. | | `page` | Page size and start position, in that order — `page=,`. | | `find` | Free-text search across attributes. | | `findin` | Restrict which attributes `find` searches. | | `asof` | Return data as it existed at a given moment or date and time. | | `deleted` | Boolean. Include deleted assets in the result. | | `needTotal` | Boolean. Compute the `total` count. The default comes from the `ApiDefaultNeedTotal` instance setting. | | `op` | Execute an operation. `POST` only, with an `AssetType` and `AssetID`. | | `Accept` | Override content negotiation when you cannot set headers. See [Content types](#content-types). | :::warning[Not every parameter applies to every request shape] On a single-asset request in the `Data` namespace such as `/Data/Story/1364`, the parameters that only make sense for a list — `where`, `sort`, `page` and `find` — are **accepted and silently ignored**. No error is raised, so a query that looks like it filtered may simply have returned the asset unchanged. `sel`, `with`, `asof`, `deleted` and `needTotal` _are_ applied on that same URL, so this is not a case of "list parameters don't work here" — some work and some are dropped. This is a property of the `Data` namespace, **not of single-asset URLs in general**: `/Hist/Story/1364` is also a single-asset request, and there `where`, `sort`, `page` and `find` all take effect. History requests also gain an implicit ascending `Moment` sort when `sort` is absent. The [query grammar reference](/reference/query-grammar) publishes the full applicability matrix along with the syntax of each parameter. ::: :::note[`needTotal` and paging] If you drive a pager off the `total` attribute of the response, set `needTotal=true` explicitly rather than relying on the instance default. When `needTotal` is off, a paged response **omits `total` entirely** — it is not written as `-1` or any other sentinel. (Unpaged responses always carry a real `total`.) Because the default comes from the instance-configurable `ApiDefaultNeedTotal` setting, a pager that works against one instance can silently break against another. ::: ## Reading Data Ask for a list, choose your attributes, and filter: ```bash title="Stories with no remaining effort, owned by Member 20" curl -u "$V1_USER:$V1_PASSWORD" \ "~/rest-1.v1/Data/Story?sel=Name,ToDo&where=ToDo='0';Owners='Member:20'" ``` ```xml Company History 0 ``` Every `Asset` element carries an `href` you can follow and an `id` that is the asset's OID. The same read as JSON (`Accept: application/json`) wraps every value in an `Attributes` map, renders a relation as a `Relation` whose value is one `{ "idref": … }` or a list of them, and renders `AssetState` as a number (see [AssetState values](/reference/attribute-types#assetstate-values)). Selecting a relation also brings its display attributes along uninvited - `Owners.Name` and `Owners.Nickname` beside `Owners`, `Status.Name` beside `Status` - as ordinary attributes, whether or not you asked for them: ```json title="…&sel=Name,Owners,AssetState — what comes back" { "_type": "Assets", "total": 1, "pageSize": 1, "pageStart": 0, "Assets": [ { "_type": "Asset", "href": "/VersionOne/rest-1.v1/Data/Story/1364", "id": "Story:1364", "Attributes": { "Name": { "_type": "Attribute", "name": "Name", "value": "Company History" }, "Owners": { "_type": "Relation", "name": "Owners", "value": [{ "_type": "Asset", "href": "…/Member/20", "idref": "Member:20" }] }, "Owners.Name": { "_type": "Attribute", "name": "Owners.Name", "value": ["Alfred Smith"] }, "Owners.Nickname": { "_type": "Attribute", "name": "Owners.Nickname", "value": ["alfred"] }, "AssetState": { "_type": "Attribute", "name": "AssetState", "value": 128 } } } ] } ``` An unset single relation renders `"value": null`; an empty multi-value one `"value": []`. `total` is present only when `needTotal=true`. [Query examples](/apis/rest-1/query-examples) works through thirteen progressively more involved queries, from a single asset to text search and history. ## Writing Data Updates are a `POST` to the URL of the asset, with a body describing only what should change. Each change is marked with an `act`: | `act` | Applies to | Effect | | -------- | ------------------------- | ------------------------------------------ | | `set` | Scalars, single relations | Replace the value. | | `add` | Multi-value relations | Add a value to the collection. | | `remove` | Multi-value relations | Remove a value from the collection. | ```bash title="Update one attribute" curl -u "$V1_USER:$V1_PASSWORD" \ -H "Content-Type: text/xml; charset=utf-8" \ --data-binary '555-555-1212' \ "~/rest-1.v1/Data/Member/20" ``` :::warning `act` goes on the value element, and the wrong placement is silent On a multi-value relation, `act` belongs on the **`Asset` element inside the `Relation`**, not on the `Relation` element itself: ```xml ``` Putting it on the `Relation` — `` — returns `200`, consumes a moment, and **adds nothing**. The response looks like success, so read the relation back when a relation update matters. ::: The response echoes the saved asset at a URL that includes the moment the change created, giving you a permanent address for exactly this version: ```xml 555-555-1212 ``` Creating an asset is the same request without an `AssetID` — `POST` to `/rest-1.v1/Data/Story` — and all required attributes must be supplied or the save fails. [Update examples](/apis/rest-1/update-examples) covers scalars, single-value relations, multi-value relations and creation. ### Recording Why a Change Was Made Add a `comment` query-string parameter to any write to attach an audit comment to that change — the same field the UI records when a user edits an asset: ```bash curl -u "$V1_USER:$V1_PASSWORD" -X POST -H "Content-Type: text/xml; charset=utf-8" --data-binary '5' "${AGILITY_BASE_URL}/rest-1.v1/Data/Story/1024?comment=Re-estimated%20after%20grooming" ``` It works on operations too — `?op=Inactivate&comment=…`. The comment is not an attribute you set; it is read back from history as [`ChangeComment`](/concepts/moments-and-history#reading-history-as-a-relation). ### Operations Some state changes are not attribute edits. Closing, reopening, deleting and similar transitions are **operations**, invoked with `op` on a `POST`: ```bash curl -u "$V1_USER:$V1_PASSWORD" -X POST \ "~/rest-1.v1/Data/Story/1021?op=Delete" ``` Which operations an asset type supports, and when each is valid, is described by [`meta.v1`](/apis/meta-v1). See [Operations](/apis/rest-1/operations). :::note A redundant operation is not reliably rejected Some redundant invocations fail a validation rule — `Inactivate` on an already inactive asset is a `400` `Violation'AssetCannotInactivateClosed` — but others succeed: `Reactivate` on an **Active** asset returns `200` and writes a **new moment** every time, changing nothing but the history. A `200` from an operation proves the request was accepted, not that a transition happened; check the asset's state when it matters. ::: ## History Swap `Data` for `Hist` and you get every version of every matching asset instead of just the current one, with each version addressable by moment. Add `asof` to ask what things looked like at a point in time: ```bash curl -u "$V1_USER:$V1_PASSWORD" \ "~/rest-1.v1/Hist/Story/1364?asof=2005-08-03T17:30:00.00&sel=Name,ToDo,ChangeDate" ``` Moments are global to the instance and therefore sparse per asset, which has real consequences for how you walk history. [Moments & history](/concepts/moments-and-history) explains the model in full. ## Content Types {#content-types} XML is the canonical format: responses default to `text/xml`, and that is the format the in-product reference and every example in these pages uses. The handler also negotiates other representations. Set an `Accept` header — or, if you cannot set headers, an `Accept` query string parameter — to one of: | `Accept` | Response | | ------------------- | ----------------------------------- | | `text/xml`, `application/xml`, `*/*` | XML (the default). | | `application/json`, `text/json` | JSON. | | `application/jsonx` | JSON with a more explicit, XML-shaped structure. | An unrecognised `Accept` value is rejected with `406 Not Acceptable`. `POST` bodies are read as XML unless the request's `Content-Type` begins with `application/json`, in which case a JSON payload of this shape is accepted: ```json title="JSON equivalent of the update above" { "Attributes": { "Phone": { "value": "555-555-1212", "act": "set" } } } ``` Relations take an OID token as their value, and multi-value relations take an array whose entries each carry their own `act`: ```json { "Attributes": { "Scope": { "value": "Scope:0", "act": "set" }, "Members": { "value": [ { "idref": "Member:1000", "act": "add" }, { "idref": "Member:1001", "act": "remove" } ] } } } ``` :::warning The concise `api/asset` JSON is not accepted here `rest-1.v1` requires the `Attributes` wrapper shown above. The concise Bulk API shape — `{ "from": "Story:1207", "update": { … } }` — is **rejected** with `400 Invalid request payload: missing Attributes`. The two surfaces take different JSON: use `Attributes` on `rest-1.v1`, and the `from`/`update` form on [`api/asset`](/apis/asset-bulk). A payload written for one does not work on the other. ::: :::note[XML is better documented] The JSON request and response formats are implemented and supported, but the in-product API reference documents only XML, so XML is the safer choice when you need behaviour to be predictable across versions. Prefer XML unless you have a specific reason not to. ::: ## Related Topics - [Information model](/apis/rest-1/information-model) — asset types, attribute definitions, assets, attributes and moments. - [Query examples](/apis/rest-1/query-examples) — thirteen worked `GET` queries. - [Update examples](/apis/rest-1/update-examples) — worked `POST` payloads. - [New asset templates](/apis/rest-1/new-asset-templates) — the `New` namespace. - [Operations](/apis/rest-1/operations) — invoking operations with `op`. - [XML responses](/apis/rest-1/xml-responses) — the response document structure. - [Security & errors](/apis/rest-1/security-and-errors) — authentication and status codes. - [Meta API](/apis/meta-v1) — discover the asset types and attributes to address here. - [Query grammar](/reference/query-grammar) — `sel`, `where`, `sort`, `page`, `find`, `findin` and `asof` syntax. - [Query size and performance](/guides/query-size-and-performance) — there is no default page size; send `page` on every list request. - [Bulk API](/apis/asset-bulk) — batch many creates, updates and operations into a single request. --- # Information Model - URL: https://docs.digital.ai/agility-api/apis/rest-1/information-model - Markdown: https://docs.digital.ai/agility-api/apis/rest-1/information-model.md - Source: apis/rest-1/information-model.md - Description: Asset types, attribute definitions, assets, attributes and moments - the five concepts the whole Agility API is built on. - Tags: rest-1, concepts, moment, history, information-model See [Moments and history](/concepts/moments-and-history) for a fuller treatment of moments and how they drive `Hist` and `asof` queries. :::info[History in Agility] In a more general sense, history is made up of events that occur at specific times or moments. Consider running into an old friend at exactly 9:32 PM. At that exact moment an event occured, you and an old friend met. In Agility a change to an asset is an event, which causes a specific moment to be recorded in the history. (A few attributes, such as rank order, are exceptions — their changes are not recorded.) For example changing a Story asset then changing a Scope asset would occur at two different times or moments. When a specific asset changes its own individual history is marked as changing as part of that moment. So for example, you create a brand new Story asset, it may get marked with moment number 203. If you then update the Story To Do attribute, the Story will be marked as having changed at moment number 204. If someone else then updates a Member Name attribute, the Member will be marked with moment number 205. Moment numbers are sequential, but are maintained globally for the entire Agility instance. The next global change in the system will be marked with moment 206. In the above example the Story history will have been marked with two distinct moment numbers. The first being moment number 203, the moment the asset was created and the second being when the To Do attribute was changed, at moment number 204. As mentioned above moment numbers are not sequential for a specific asset, they are sequential for the entire instance. Between the time you created the Story asset and the time you changed its To Do attribute, another user may have changed an entirely different asset in the instance, thus making a gap in between your Story asset's moments. ::: Practically all data in Agility is stored in the form of assets, which have attributes. Each asset is classified by an asset type, which describes a number of attribute definitions, operations, rules, and possibly an inheritance from another asset type. ## Asset Type {#infomodel-assettype} Asset types describe the "classes" of business data available. Asset types form an inheritance hierarchy, such that each asset type inherits attribute definitions, operations, and rules from its "parent" asset type. Those asset types at the leaves of this hierarchy are *concrete*, whereas asset types with "children" asset types are *abstract*. Assets are all instances of concrete asset types. Asset types are identified by unique names. By way of example, Story and Defect are concrete asset types. On the other hand, Workitem is an abstract asset type, from which Story and Defect ultimately derive. ## Attribute Definition {#infomodel-attribdef} Attribute definitions describe the properties that "make up" each asset type. An attribute definition defines the type of its value, whether it is required and/or read-only, and many other qualities. Attribute definitions are identified by a name that is unique within its asset type. Attribute definitions are defined as either scalars or relations to other assets. Further, relation attribute definitions can be either single-value or multi-value. For example, the Estimate attribute definition on the Workitem asset type is a scalar (specifically, a floating-point number). On the other hand, the Workitem asset type's Scope attribute definition is a single-value relation (to a Scope asset). The reverse relation, Workitems on the Scope asset type, is a multi-value relation (to Workitem assets). ## Asset {#infomodel-asset} Actual business objects in Agility are assets, which are instances of concrete asset types. Each asset is uniquely identified by it's asset type and ID (an integer). For example, *Member:20* identifies the Member asset with ID of 20. ## Attribute {#infomodel-attribute} On every asset are a number of attributes, which attach specific values to the attribute definitions defined in the asset type. If the attribute's definition is a relation, then the value(s) of the attribute are references to an asset(s). ## Moment {#infomodel-history} As data changes in Agility, a history is maintained. Every change to every asset is journaled within the system, and assigned a chronologically-increasing integer called a *moment*. A past version of an asset is uniquely identified by it's asset type, ID, and Moment. A past version of a relation attribute will refer to the past version of it's target asset. For example, *Member:20:563* identifies the Member asset with ID of 20, as it was at the time of moment 563. --- # Query Examples - URL: https://docs.digital.ai/agility-api/apis/rest-1/query-examples - Markdown: https://docs.digital.ai/agility-api/apis/rest-1/query-examples.md - Source: apis/rest-1/query-examples.md - Description: Worked rest-1.v1 GET queries with real response payloads, from a single asset through filtering, sorting, paging, text search and history. - Tags: rest-1, query, examples, history, asof This section is a series of examples, starting with simpler queries and moving to more advanced queries. Any of these examples are viewable directly in a web browser capable of displaying XML data. ## How to Query a Single Asset {#query-data-asset} This URL will retrieve the Member with ID 20, with a set of common attributes: `~/rest-1.v1/Data/Member/20` ```xml 64 System Admin admin false Administrator Member Admin admin@demo.com ``` :::note[Remarks] The XML returned will start with a root node called "Asset" which indicates the results are for a single asset not a list of assets. The "Asset" node also contains an "href" attribute, which corresponds to the full URL used to retrieve this asset, as well as an "id" attribute which contains the internal VersionOne ID of the specific asset. The child nodes of an "Asset" node are a mixture of "Attribute" and "Relation" nodes. Both types of nodes contain a "name" attribute which tells you what Attribute the value corresponds to. While "Attribute" nodes contain their value, a "Relation" node contains an "Asset" sub node that describes the related asset. Note how the "Asset" sub node contains an "href" attribute like the root "Asset" node but contains an "idref" attribute instead of an "id" attribute. All assets reside at URLs of the same format; "Member" and "20" can be replaced with any asset's type and ID. If no such asset exists, or if you do not have sufficient rights to view it, then the response will be [404 Not Found](/apis/rest-1/security-and-errors#error-404). ::: ## How to Query for Different Attributes {#query-data-sel-single} Retrieve attributes other than the default set by using the [*sel* query parameter](/reference/query-grammar). `~/rest-1.v1/Data/Member/20?sel=Name,Email` ```xml admin@demo.com Administrator ``` ::::note[Remarks] The response contains only those attributes specified. :::tip[Best Practice] Whenever possible, use the *sel* query parameter to choose specific attributes, thereby reducing the load put on the API. ::: :::: ## How to Query the Value of a Single Attribute {#query-data-att} Just as each asset resides at its own URL, so does each attribute. This URL retrieves the Name attribute on Member 20: `~/rest-1.v1/Data/Member/20/Name` ```xml Administrator ``` :::note[Remarks] The XML returned will start with a root node called "Attribute" or "Relation" which indicates the results are for a single attribute on a single asset. The root node also contains a "name" attribute, which identifies the attribute the value corresponds to, an "href" attribute, which corresponds to the full URL used to retrieve this attribute, as well as an "of" attribute which corresponds to the full URL used to retrieve the asset this attribute belongs to. If the root node is "Attribute" the scalar value will be in a Text node inside the root node. If the root node is "Relation" there may be one or more "Asset" nodes which are references to the assets the asset relates to. ::: ## How to Get a List of All Story Assets {#query-assets} This URL will retrieve all Story assets (that you have rights to see): `~/rest-1.v1/Data/Story?page=100,0` ```xml Logon Story 128 S-01001 32 2 ``` :::note[Remarks] Depending on your security role, you may not be able to see all the Story assets in the entire system. The XML returned will start with a root node called "Assets" which indicates the results are list of assets. The "Assets" node also contains a "total" attribute, which tells you how many total items are available if paging is not used, as well as "pageStart" and "pageSize" attributes which indicate where in the given query the items belong. See the [Paging Syntax](/reference/query-grammar) for more information on paging queries. There is no default page size. A query without a *page* query parameter returns *every* asset that matched, however many that is. Every example in this section therefore includes *page*, and so should every query an integration issues. Note that the *total* attribute still reports how many assets matched *in total*, which is how you know whether more pages remain. ::: ## How to Query Specific Attributes for a List of Story Assets {#query-data-sel-multi} Use the [*sel* query parameter](/reference/query-grammar) to retrieve attributes other than the default set. `~/rest-1.v1/Data/Story?sel=Name,DetailEstimate&page=100,0` ```xml Company History 0 Credit Check 1 ``` ## How to Filter a Query {#query-data-where} Filter a query by using the [*where* query parameter](/reference/query-grammar). This URL will retrieve only Story assets with a To Do of zero: `~/rest-1.v1/Data/Story?sel=Name,ToDo&where=ToDo='0'&page=100,0` ```xml Home Page 0 Contact Us 0 ``` :::note[Remarks] The number 0 in the *where* query parameter is surrounded by single-quote marks. ::: ## How to Sort a Query {#query-data-sort} Sort a query by using the [*sort* query parameter](/reference/query-grammar). This URL will retrieve Story assets sorted by increasing Estimate: `~/rest-1.v1/Data/Story?sel=Name,Estimate&sort=Estimate&page=100,0` ```xml Ticket 3432 1 Ticket 3431 2 Shipping Lanes - Freight 3 ``` :::note[Remarks] To sort in descending order prepend the attribute name with a minus sign: `sort=-Estimate`. ::: ## How to Select a Portion of Query Results {#query-data-page} Retrieve a "page" of query results by using the [*page* query parameter](/reference/query-grammar). This URL will retrieve the first 5 Story assets: `~/rest-1.v1/Data/Story?sel=Name&page=5,0` ```xml Logon Ticket 3428 Ticket 3429 Ticket 3430 Ticket 3431 ``` :::note[Remarks] The *page* query parameter shown asks for 5 items, starting at position 0. The next 5 items can be retrieve with `page=5,5`. The *pageSize* and *pageStart* attributes reflect the requested page, and the *total* attribute indicates how many assets satisfied the query in total. Note that the page size comes first and the starting position second. Reversing them is silent: `page=0,50` asks for a page of zero items rather than the first fifty. Paging is not optional in practice. Because there is no default page size, an unpaged query against a mature instance can attempt to return tens of thousands of assets in a single response. Combined with the default attribute set returned when *sel* is omitted, this is the most common cause of avoidable load on an Agility instance. Send both *page* and *sel* on every query an integration issues, and a [*sort*](#query-data-sort) as well, so that paging is stable between requests. ::: ## How to Search Text Attributes {#query-data-find} Search for a string in text attributes by using the [*find* and *findin* query parameters](/reference/query-grammar). This URL will retrieve all Story assets whose Name attribute contains the word *Ticket*. `~/rest-1.v1/Data/Story?sel=Name&find=Ticket&findin=Name&page=100,0` ```xml Ticket 3429 Ticket 3449 Ticket 3439 ``` :::note[Remarks] The *findin* query parameter is optional; if omitted, a base set of attributes will be searched. On the other hand, the *findin* query parameter may contain multiple text attributes as a comma-delimited list: `findin=Name,Description,Reference`. ::: ## How to Query the History of a Single Asset {#query-hist-asset} This URL will retrieve the history of the Member asset with ID 20. `~/rest-1.v1/Hist/Member/20` ```xml Admin 64 System Admin admin false Administrator Member Admin admin@demo.com 64 System Admin admin false Administrator 555-555-1212 Member ``` :::note[Remarks] This example queries from the *Hist* namespace, instead of the *Data* namespace. It responds with 2 historical versions of the same asset. The differences between the two historical assets may be difficult to spot—both the Email attribute and the Phone attribute changed. The root node is named "History" to indicate you are viewing a historical result. The "href" attribute on each "Asset" node contains a moment after the asset type and ID portions of the path. That URL provides the path to the exact version of that specific asset, as it was at that moment. Note how the first asset can be retrieved using moment number 0, while the second can be retrieved using moment number 113. This indicates that over 100 changes occured to other assets in the system before someone changed this Member asset. ::: ## How to Filter a History Query Without an OID {#query-hist-filter} The `Hist` namespace is not limited to a single asset addressed by ID. You can query the whole collection and narrow it with `where`, exactly as in the `Data` namespace — history is just another queryable projection: `~/rest-1.v1/Hist/Member?where=IsSelf='true'&sel=Name` That returns every historical version of the matching members. Filtering on a business attribute — `Hist/Member?where=Email='someone@example.com'` — is often more convenient than first resolving the OID and then addressing it by path. Because a `Hist` query returns **every version** of every matching asset, keep it narrow: filter tightly and select only the attributes you need. ## How to Query the History of a Single Attribute {#query-hist-att} This URL will retrieve the history of changes to the Email attribute of the Member asset with ID 20: `~/rest-1.v1/Hist/Member/20/Email` ```xml admin@demo.com ``` :::note[Remarks] The root "History" node contains child nodes named "Attribute", indicating this is a historical query for a single attribute. As with normal attribute queries, the "Attribute" node contains a "name" attribute which identifies the name of the attribute on the asset, an "href" attribute which points to the specific attribute, and finally an "of" attribute which points to the asset the attribute is on. Note how both the "of" and "href" attributes contain a URL with a moment number. ::: ## How to Query the History of Many Assets {#query-hist-assets} This URL will retrieve history for all Story assets you are able to see: `~/rest-1.v1/Hist/Story?sel=Name,ToDo,ChangeDate&page=100,0` ```xml Company History 40 2005-08-01T12:28:35.577 Company History 20 2005-08-02T11:05:12.123 Company History 0 2005-08-03T13:09:05.983 ``` :::note[Remarks] Again, the response is a list of historical assets. Note how each "Asset" has an "href" attribute with a moment number. Paging matters more here than on a Data query. A history query returns one asset per *version* rather than one per asset, so an unpaged history query across an entire asset type is typically the largest response the API can produce. See the [Paging Syntax](/reference/query-grammar). One of the attributes requested in this example is the ChangeDate attribute. This attribute is maintained by history and it indicates the exact time this change occurred. All of the previously demonstrated [query parameters](/reference/query-grammar) can be used with historical queries also. ::: ## How to Query an Asset "as of" a Specific Time {#query-hist-asof} Use the [*asof* query parameter](/reference/query-grammar) to retrieve data as it existed at some point in time. This URL finds the version of a Story asset as it existed at 5:30 PM on August 3rd, 2005: `~/rest-1.v1/Hist/Story/1364?asof=2005-08-03T17:30:00.00&sel=Name,ToDo,ChangeDate` ```xml Company History 2005-08-03T13:09:05.983 0 ``` :::note[Remarks] This query returns the last historical record on or before the date specified in the *asof* query string. Notice how the ChangeDate attribute indicates it was last changed at 1:09 PM on August 3rd, 2005. Between 1:09 PM and 5:30 PM no other changes occurred for this Story asset. The *asof* query parameter can also be used on single-attribute and multi-asset historical queries. ::: --- # Update Examples - URL: https://docs.digital.ai/agility-api/apis/rest-1/update-examples - Markdown: https://docs.digital.ai/agility-api/apis/rest-1/update-examples.md - Source: apis/rest-1/update-examples.md - Description: Worked rest-1.v1 POST payloads for scalar attributes, single-value relations, multi-value relations and asset creation. - Tags: rest-1, update, create, examples Updating assets through the API involves sending POST requests to the URL of the asset to be updated, with an XML payload. These examples can be tested in your browser by using the page `~/http.html`. ## How to Update a Scalar Attribute on an Asset {#lbe-update-simple} Updating a scalar attribute on an asset is accomplished by marking the attribute with `act="set"`, and filling in the new value in the element's text. This post will update the Phone attribute of the Member with ID 20: ```text POST /VersionOne/rest-1.v1/Data/Member/20 HTTP/1.1 Content-Type: text/xml; charset=utf-8 Content-Length: 78 555-555-1212 ``` The response is: ```xml 555-555-1212 ``` :::note[Remarks] The response includes the URL of the exact version of the asset that was just updated. ::: ## How to Update a Single-Value Relation on an Asset {#lbe-update-relation} Updating a single-value relation is accomplished by marking the attribute with `act="set"`, and filling the idref of the enclosed Asset element. This post will change the Owner of the Scope with ID 0 to be the Member with ID 20: ```text POST /VersionOne/rest-1.v1/Data/Scope/0 HTTP/1.1 Content-Type: text/xml; charset=utf-8 Content-Length: 98 ``` Whereas this post will change the Owner of the Scope with ID 0 to nobody (that is, NULL): ```text POST /VersionOne/rest-1.v1/Data/Scope/0 HTTP/1.1 Content-Type: text/xml; charset=utf-8 Content-Length: 67 ``` ## How to Add and Remove Values from a Multi-Value Relation {#lbe-update-multi} Updating a multi-value relation is accomplished by marking enclosed Asset elements with either `act="add"` or `act="remove"`. This post will add one Member and remove another Member from the Scope with ID 0: ```text POST /VersionOne/rest-1.v1/Data/Scope/0 HTTP/1.1 Content-Type: text/xml; charset=utf-8 Content-Length: 197 ``` ## How to Create a New Asset {#lbe-update-new} Creating a new asset is accomplished by posting to the asset type URL (without an ID). This post will create a Story with the name "New Story", within the Scope with ID 0. ```text POST /VersionOne/rest-1.v1/Data/Story HTTP/1.1 Content-Type: text/xml; charset=utf-8 Content-Length: 221 New Story ``` The response is: ```xml New Story ``` :::note[Remarks] When creating a new asset, all required attributes must be provided, or the save will fail. In the above example, the Name and Scope attributes are the minimum required attributes for a Story. ::: --- # New Asset Templates - URL: https://docs.digital.ai/agility-api/apis/rest-1/new-asset-templates - Markdown: https://docs.digital.ai/agility-api/apis/rest-1/new-asset-templates.md - Source: apis/rest-1/new-asset-templates.md - Description: The rest-1.v1 New endpoint, which returns a pre-populated, context-aware payload you can edit and post back to create an asset. - Tags: rest-1, create, templates, examples A new asset template is an XML response that contains suggested attribute values for a new asset. These values can be changed, and new attributes added; then the whole template can be posted for saving. Often a new asset should be created in the "context" of another asset. For example, if you ask for a new Story asset in the context of an Issue asset, the new Story asset will carry over some attributes from the Issue. A few of these attributes are the Name, Description, Scope, and a relationship back to the originating Issue. The same can be done with a Story in the context of a Request with similar attributes being carried across. One example of a new asset template is asking for a new Timebox asset. When a new Timebox asset is asked for in the context of a Scope asset, it will automatically come with a suggested Name, as well as a StartDate and EndDate. This saves the client from having to calculate the StartDate and EndDate attributes. These examples demonstrate how to ask for new assets. They should be viewable using a web browser capable of displaying XML data. ## How to Get a New Story Asset Template in the Context of a Scope Asset {#lbe-newasset-storyfromscope} Use this URL to get a new Story template. `~/rest-1.v1/New/Story?ctx=Scope:1000` The [XML Response](/apis/rest-1/xml-responses) will look something like this. ```xml ``` :::note[Remarks] Note how the response contains a pending change for the Scope attribute. This Story asset is not saved yet. To save it (creating a new Story asset), you must add a Name attribute, and POST it to `~/rest-1.v1/Data/Story` as described in [How to create a new asset](/apis/rest-1/update-examples#lbe-update-new). ::: ## How to Get a New Story Asset in the Context of a Request Asset {#lbe-newasset-storyfromrequest} Use this URL to get a new Story template. `~/rest-1.v1/New/Story?ctx=Request:1684` The [XML Response](/apis/rest-1/xml-responses) will look something like this. ```xml

When an item's status is updated, alert the owner of the change in status value.

Joe Customer Integrate with Palm Handheld
``` Note how the response contains many attributes carried over from the Request asset into the pending Story asset. Also note how the Story will have a relationship set to point back to the Request that it was created from. --- # Operations - URL: https://docs.digital.ai/agility-api/apis/rest-1/operations - Markdown: https://docs.digital.ai/agility-api/apis/rest-1/operations.md - Source: apis/rest-1/operations.md - Description: Executing named operations such as Delete, Inactivate, Reactivate and Activate against a single asset with the op query parameter. - Tags: rest-1, operations, delete, workflow An operation is an action that is executed against a single asset. For example, to delete an asset you must execute the Delete operation on the asset. To close or inactivate a Workitem, you must use the Inactivate Operation. When executing an operation a request must be made to the URL using the POST HTTP method. ## How to Delete a Story Asset {#lbe-operations-delete} Post a request to this URL with an empty or 0 byte request. `~/rest-1.v1/Data/Story/1021?op=Delete` The [XML Response](/apis/rest-1/xml-responses) will look something like this. ```xml ``` Notice the returned "Asset" node which indicates the asset was properly updated or in this example deleted. The "href" attribute can be used to retrieve the asset in its deleted state. When deleting an asset from the system, the asset is simply marked as deleted. Future current info queries will automatically excluded deleted assets from results. A deleted asset can be restored with the [Undelete operation](#lbe-operations-undelete). ## How to Undelete a Story Asset {#lbe-operations-undelete} Post a request to this URL with an empty or 0 byte request. `~/rest-1.v1/Data/Story/1021?op=Undelete` The [XML Response](/apis/rest-1/xml-responses) will look something like this. ```xml ``` The asset is restored and appears in current info queries again. Undelete can be refused where restoring would conflict with current state; for example, a workitem whose parent has since been closed cannot be undeleted. ## How to Close a Story Asset {#lbe-operations-close} Post a request to this URL with an empty or 0 byte request. `~/rest-1.v1/Data/Story/1022?op=Inactivate` The [XML Response](/apis/rest-1/xml-responses) will look something like this. ```xml ``` The returned "Asset" node indicates the asset was properly updated. You can use the "href" attribute to retrieve the Story asset and see the AssetState attribute changed. The AssetState attribute is the internal state of an asset. ## How to Reopen a Story Asset {#lbe-operations-reactivate} Post a request to this URL with an empty or 0 byte request. `~/rest-1.v1/Data/Story/1022?op=Reactivate` The [XML Response](/apis/rest-1/xml-responses) will look something like this. ```xml ``` The returned "Asset" node indicates the asset was properly updated. You can use the "href" attribute to retrieve the Story asset and see the AssetState attribute changed. ## How to Activate a Timebox Asset {#lbe-operations-activate} Post a request to this URL with an empty or 0 byte request. `~/rest-1.v1/Data/Timebox/2011?op=Activate` The [XML Response](/apis/rest-1/xml-responses) will look something like this. ```xml ``` The returned "Asset" node indicates the asset was properly updated. --- # XML Response Reference - URL: https://docs.digital.ai/agility-api/apis/rest-1/xml-responses - Markdown: https://docs.digital.ai/agility-api/apis/rest-1/xml-responses.md - Source: apis/rest-1/xml-responses.md - Description: The shape of every rest-1.v1 response: Data and Hist queries by asset type, asset and attribute, plus update, New and operation responses. - Tags: rest-1, reference, xml, responses Responses returned from the API have the content type "text/xml", by default. The syntax of the responses varies depending on the query, update, or operation executed. ## Query Responses {#xmldesc-query} Responses for queries vary depending on what information was asked for. First, queries are seperated into historical and non-historical, and then seperated into the type of query. These types include Asset Type, Asset, and finally Attribute queries. ### Query Data Responses {#xmldesc-query-current} Data queries are the result of asking for a URL using the "/Data" portion of the path. These queries are for the current state of assets not the historical states. #### Asset Type Query {#xmldesc-query-current-assettype} The Asset Type query is a query to retrieve all assets of a given asset type in a single list. This list can be sorted, filtered, and paged. The response will begin with an "Asset" node and it will contain attributes named "total", "pageSize", and "pageStart". The "total" attribute correspond to the total number of assets that would appear if the query was not paged. The "pageSize" attribute indicates how many assets were returned in a given page. Finally the "pageStart" attribute indicates where in the un-paged asset list this response begins at. Inside the "Assets" node are any number of "Asset" nodes which correspond to the individual assets that met the filtered parameter. Each "Asset" node contains an "href" attribute which indicates the URL that the given asset resides at and it also contains an "id" attribute which is the internal ID used by VersionOne to uniquely identify the given asset. Under each "Asset" node are any number of "Attribute" and "Relation" nodes. These nodes correspond to the attributes that make up the given instance of an asset type. Both nodes will contain a "name" attribute which identifes the name of the attribute the node represents. "Attribute" nodes will contain their value as an inner Text element. "Relation" nodes will contain one or more "Asset" nodes which are references to other assets that this specific asset relates to. Each "Asset" reference node will contain an "href" attribute which points to the instance of the related asset, and an "idref" attribute which identifies the unique VersionOne id of the related asset. ```xml Logon Story 128 S-01001 32 2 ``` #### Asset Query {#xmldesc-query-current-asset} An asset response is returned when a specific asset is asked for. It corresponds to the "Asset" sub node under the "Assets" root node described in the previous section. And example of an asset query is show below. ```xml 64 System Admin admin false Administrator Member Admin admin@demo.com ``` #### Attribute Query {#xmldesc-query-current-attribute} And attribute response is returned when a specific attribute on a specific asset is asked for. As described in the [Asset Type Query](#xmldesc-query-current-assettype) section above, the node name may either be "Attribute" or "Relation". These nodes will also contain an "of" attribute which is a URL that points to the asset that this attribute is on. ```xml Administrator ``` ### Query Historical Responses {#xmldesc-query-hist} Historical queries are executed in the same fashion as regular queries however their response is formatted differently. This is due to the fact that multiple records or nodes are returned for a particular query. Historical queries use the "/Hist" portion of the URL path. #### Asset Type Query {#xmldesc-query-hist-assettype} When asking for historical asset types, the root node will be called "History". Under it will be any number of nodes called "Asset" which correspond to individual assets as they existed at points in time. On the "Asset" nodes the "href" and "id" attributes will now contain a moment number that indicates at what moment the asset changed. The sub nodes "Attribute" and "Relation" are displayed in the same manner as described in the [Data Asset Type Query](#xmldesc-query-current-assettype) above. ```xml Company History 40 2005-08-01T12:28:35.577 Company History 20 2005-08-02T11:05:12.123 Company History 0 2005-08-03T13:09:05.983 ``` #### Asset Query {#xmldesc-query-hist-asset} When asking for the history of a particular asset, the results will be essentially the same as the historical asset type query above. The only difference is that the only assets returned are those that have a specific ID. Otherwise the results look identical to those returned above. ```xml Admin 64 System Admin admin false Administrator Member Admin admin@demo.com 64 System Admin admin false Administrator 555-555-1212 Member ``` #### Attribute Query {#xmldesc-query-hist-attribute} The history of an attribute will return a root node named "History" with a number of "Attribute" or "Relation" sub-nodes. Each sub-node corresponds to a moment in time when the asset the attribute exists on has changed. The "Attribute" or "Relation" node will have a "name" attribute that identifes the attribute and an "of" attribute which identifies the asset the attribute is on. Note that the "of" attribute will contain a URL with a moment. ```xml admin@demo.com ``` ## Update Responses {#xmldesc-update} The response of an update is an "Asset" node that contains an "href" attribute and "id" attribute that include the ID and Moment of the saved asset. Under the "Asset" node are sub nodes that correspond to the changed attributes and relations. ```xml 555-555-1212 ``` ## New Responses {#xmldesc-newasset} The response of a new asset query is a payload that can be posted back to the API to be saved. ```xml ``` or ```xml

When an item's status is updated, alert the owner of the change in status value.

Joe Customer Integrate with Palm Handheld
``` The response contains an "Asset" node and multiple "Attribute" or "Relation" sub nodes that identify the values that the new asset will contain. Note that these pending attributes and relations are pre-marked with the "act" attribute which is used to mark the pending changes. ## Operation Responses {#xmldesc-operation} The response of an operation is generally an "Asset" node with an "href" attribute and "id" attribute containing an ID and Moment. ```xml ``` or ```xml ``` --- # Security and Errors - URL: https://docs.digital.ai/agility-api/apis/rest-1/security-and-errors - Markdown: https://docs.digital.ai/agility-api/apis/rest-1/security-and-errors.md - Source: apis/rest-1/security-and-errors.md - Description: How rest-1.v1 authenticates requests, and the error payload and status codes it returns when something goes wrong. - Tags: rest-1, authentication, errors, status-codes ## Security {#security} All API requests must be authenticated, which associates the request with a specific Member in the system. The results of the request will then be secured according to that Member's Roles defined within the system. A request that cannot be authenticated will result in a [401 Unauthorized](#error-401) response. To authenticate, send a personal access token in the HTTP Authorization header using the Bearer scheme. Access tokens are created in the application from the Member's Applications settings, and each token can be revoked independently without changing the Member's credentials. The request is processed in the context of the Member the token belongs to. :::warning[Prefer access tokens] Basic Authentication with a username and password is not likely to be available: instances configured for single sign-on do not accept passwords for API requests, and administrators commonly disable password access entirely. ::: ## Errors {#errors} When a request results in an error, the response will have an HTTP status code in the range 400-500, and detailed information about the error will be returned in the response payload. For example, the request `~/rest-1.v1/Data/Story?sel=Bogus` results in a [400 Bad Request](#error-400) response with this payload: ```xml Invalid SEL parameter Unknown AttributeDefinition: Story.Bogus ``` The response is an Error element, with an href attribute indicating the offending requested URL. The Message element contains a textual description of the error. Optional Exception elements may contain technical details about the cause of the error. ### Common Error Status Codes {#error-codes} #### 400 Bad Request {#error-400} A 400 status code is the most common error, resulting from a request that could not be processed by the API. The Error Message will identify the specific problem, and there may be Exception elements that give very detailed information. The usual causes of this error are: - An invalid parameter on the query string. - An invalid payload on a POST request. #### 401 Unauthorized {#error-401} A 401 status code indicates that the request could not be authenticated. This may be caused by any one of these conditions: - No Authorization header or credentials supplied with the request. - An invalid or revoked access token. - An invalid username or password supplied to Basic Authentication, where it is enabled. The typical course of action would be to obtain a valid access token and resend the request. #### 404 Not Found {#error-404} A 404 status code indicates that there is no resource to retrieve at the requested URL. This could be due to: - An invalid URL path requested. - A request for a single asset which does not exist, or is deleted, or is secured from the user's view. #### 405 Method Not Allowed {#error-405} A 405 status code indicates that the HTTP method used for the request is not valid at the requested URL. This will be returned for any method other than GET and POST, or if POST was sent to a URL that does not support updating. #### 500 Internal Server Error {#error-500} A 500 status code indicates that an unexpected error occurred during the processing of the request. The response payload will contain Exception elements with specific technical information that will help diagnose the cause of the error. This could be due to: - An environmental problem (e.g., the database is unavailable). - An unhandled exception within the Agility software. --- # Query API (query.v1) - URL: https://docs.digital.ai/agility-api/apis/query-v1 - Markdown: https://docs.digital.ai/agility-api/apis/query-v1.md - Source: apis/query-v1/index.md - Description: Retrieve Agility assets with flexible filtering, sorting, and pagination - Tags: api, query, read, filter The Query API retrieves Agility assets using a structured request body rather than a query string. It is the most convenient endpoint for reading data when you are composing a query in code, because the request is JSON or YAML and the response is JSON. ## Overview - **Endpoint**: `POST ~/query.v1` - **Authentication**: required — see [Authentication](/getting-started/authentication) for the supported schemes - **Content-Type**: `application/json` (YAML is also accepted) - **Use cases**: asset lookups, filtered reports, backlog exports, workflow automation ## Key Features - **Two filtering parameters that are not interchangeable** — `where` is equality shorthand only; `filter` carries the full expression language. They are named the opposite way round from `rest-1.v1`, which is the single most common source of confusion between the two APIs. See the warning below the parameter table. - **Nested projections** — select related attributes such as `Status.Name` and `Owners.Name`, and use a nested object as a subquery. - **Sorting, grouping and paging** — `page` takes a **named mapping** here (`{"start": 0, "size": 50}`), unlike `rest-1.v1`'s positional `page=,`. - **Multiple independent queries** in a single request, returning multiple result sets. :::danger Always send `page` — there is no default limit Omitting `page` does not return a sensible first page; it returns **every** matching asset. A query with no `page` and no `where` will attempt to read the entire asset type out of your instance, and omitting `select` still returns a per-type default attribute set rather than nothing. This is the most common cause of self-inflicted performance problems in Agility integrations. See [Query size and performance](/guides/query-size-and-performance). ::: ## Request Structure ### Basic Query ```json { "from": "Story", "select": ["Number", "Name", "Status.Name", "Scope.Name"], "where": { "Number": "S-02574" } } ``` ### Complete Query Options ```json { "from": "Story", "select": [ "Number", "Name", "Description", "Status", "Status.Name", "Scope.Name", "Owners.Name", "Owners.ID", "ChangeDate", "CreateDate" ], "where": { "Scope": "Scope:1338" }, "filter": ["Status.Name='Ready'", "AssetState!='Dead'"], "sort": ["+ChangeDate", "-CreateDate"], "page": { "start": 0, "size": 50 } } ``` ## Request Parameters | Field | Required | Type | Description | | ----------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------- | | `from` | Yes | string | Asset type to query (`Story`, `Defect`, `Task`, `Test`, etc.) | | `select` | Yes | array | Attributes to retrieve; a nested object is a subquery. `sel` is an alias for the key | | `where` | No | object | Equality shorthand only — `{"Number": "S-123"}`. **Not** the filter language | | `filter` | No | string or array | [Filter expressions](/reference/query-grammar/filter-expressions) — the same language as `where` on `rest-1.v1` | | `with` | No | object | [Filter context](/reference/query-grammar/filter-context): variable name to a scalar or list | | `sort` | No | array | Sort order (`+Field` ascending, `-Field` descending) | | `page` | No | object | Pagination with `start` and `size` — **when present, both are required**; see below | | `group` | No | array | Group the results - see [Grouping](#grouping) | | `find` | No | string | Text to search for | | `findin` | No | array | Attributes `find` searches; defaults to Name and ShortName | | `asof` | No | string | A moment number or a datetime | | `deleted` | No | boolean | Include deleted assets | | `needTotal` | No | boolean | Accepted but has no effect here: a `query.v1` response is a bare result array with no `total` or other metadata. For a result count, use the Bulk API's [query command](/apis/asset-bulk/queries), whose `resultMetaData` honors it | The parser accepts exactly these keys — `update` and `execute` are silently stripped, and any other unrecognised key is rejected. :::warning `as` aliases parse but do nothing The parser accepts `"Story as s"` in `from` and `"Name as StoryTitle"` in `select` entries, then discards the alias. The response key is still `Name`, and the `from` alias cannot be referenced anywhere. Do not build on either form. ::: :::warning `page` needs both `size` and `start` `{"page": {"size": 50}}` is a server error (HTTP 500): `start` is not defaulted on this endpoint. This differs from the Bulk API's [query command](/apis/asset-bulk/queries), where `start` defaults to `0`. Send both keys. ::: :::note You may see the `IsDeleted` filter hack in existing code Older `query.v1` and `api/asset` integrations include deleted assets with `filter: ["IsDeleted='True','False'"]` rather than `deleted: true` — asserting both values defeats the server's automatic exclusion of deleted assets. Note that `IsDeleted` also selects back as the **string** `"True"`, not a boolean. Prefer `deleted: true` in new code. See [the `IsDeleted` hack](/reference/query-grammar/filter-expressions#isdeleted-hack). ::: :::warning `where` means something different here than on `rest-1.v1` On `query.v1`, `where` is a plain equality mapping and the full filter language lives under `filter`. On `rest-1.v1` there is no `filter` parameter at all and `where` **is** the filter language. So `filter: ["ToDo='0'"]` here is `?where=ToDo='0'` there. See the [full comparison table](/reference/query-grammar#which-surface-uses-what). A command may carry **both** — they combine as AND — but prefer one style per query: anything `where` can express, `filter` can too, and a reader should not have to merge two clauses to know what a query matches. `where` is equality and nothing else. Putting an operator in the value — `where: { Estimate: ">5" }` — does **not** work: the server quotes the whole value, producing `Estimate='>5'`, which is neither valid nor a greater-than test. Any comparison (`>`, `<`, `!=`, `>=`) belongs in `filter`: `filter: ["Estimate>'5'"]`. The one value that is not quoted is a `$`-prefixed [reserved variable](/reference/query-grammar/filter-context#reserved-variables): `where: { ID: "$Search" }` passes through as-is, so a `where` can match against the current search results. Every other value is treated as a literal. ::: :::danger Putting the operator in the key silently returns the wrong rows Do not try to sneak the operator onto the attribute name either. `where` builds each term as `key = 'value'`, so `where: { "Estimate>": "5" }` becomes the token `Estimate>='5'` — and that is **accepted, returns rows, and is wrong**. Two errors compound with no error raised: the intended `>` turns into `>=` (the server supplies the `=`), and the comparison runs against the string `'5'`, not the number. On one dataset `where: { "Estimate>": "5" }` returned 35 rows where the correct `filter: ["Estimate>'5'"]` returned 8. Because it looks like it works, this is easy to ship and never notice. Operators go in `filter`. ::: ## Response Format Query responses can arrive in two formats. Always normalize both: ### Nested Array Format ```json [ [ { "_oid": "Story:4786", "Number": "S-02574", "Name": "API Documentation", "Status": { "_oid": "StoryStatus:2187", "Name": "Development" }, "Status.Name": "Development", "Scope.Name": "Platform Team", "Owners.Name": ["Alice", "Bob"], "Owners.ID": [{ "_oid": "Member:1001" }, { "_oid": "Member:1002" }] } ] ] ``` :::note Scalar values arrive as JSON strings Numeric and date attributes are returned as strings — `"Estimate": "2"`, not `"Estimate": 2`. Convert before doing arithmetic or numeric comparisons on the client. ::: ## Select Projections ### Basic Attributes Request simple attributes directly: ```json "select": ["Number", "Name", "Description"] ``` ### Nested Attributes Access related object properties: ```json "select": ["Status.Name", "Scope.Name", "Owners.Name"] ``` **Best Practice**: Include both the relation object and nested attributes: ```json "select": ["Status", "Status.Name", "Owners", "Owners.Name"] ``` This handles different response formats from various Agility versions. ### Multi-Value Attributes Some attributes return arrays: ```json { "Owners.Name": ["Alice", "Bob"], "Owners.ID": [{ "_oid": "Member:1001" }, { "_oid": "Member:1002" }] } ``` ## Filtering ### Where Clause (Equality) Use `where` for exact matches: ```json { "where": { "Number": "S-02574", "Scope": "Scope:1338" } } ``` ### Filter Expressions (Advanced) Use `filter` for complex conditions: ```json { "filter": [ "Status.Name='Ready'", "Status.Name='Development'", "Scope.Name='Platform Team'", "AssetState!='Dead'" ] } ``` **Filter Operators**: the full set is `=`, `!=`, `<`, `<=`, `>`, `>=`, `~=` (LIKE), with `&` and `;` for AND, `|` for OR, `( )` for grouping, and `+`/`-` prefixes for existence tests. AND and OR have the **same precedence**, so parenthesise when mixing them. See [filter expressions](/reference/query-grammar/filter-expressions) for the complete reference. **Example**: Multiple conditions ```json { "filter": ["(Status.Name='Ready'|Status.Name='Development')&AssetState!='Dead'"] } ``` :::caution How multiple `filter` array entries combine is unverified The parser collects the entries into a list without documenting whether they are ANDed or ORed, and the examples on this page have historically implied both. Put the whole condition in a single expression string — using explicit `;`, `|` and parentheses — rather than relying on how separate array entries are joined. ::: ## Sorting Sort results by one or more fields: ```json { "sort": [ "+ChangeDate", // Ascending "-CreateDate" // Descending ] } ``` Prefix with `+` for ascending, `-` for descending. ## Pagination Retrieve large datasets in chunks: ```json { "page": { "start": 0, // Starting index (0-based) "size": 50 // Number of results per page } } ``` **Example**: Get page 2 (results 50-99) ```json { "page": { "start": 50, "size": 50 } } ``` ## Grouping `group` buckets the result set. The response nests each group's assets under a `_children` array on a group-header object. Two forms work: **A relation name.** Each distinct value of the relation becomes a group: ```json { "from": "Story", "select": ["Name"], "group": ["Status"], "page": { "start": 0, "size": 50 } } ``` ```json title="Response shape (abridged)" [ [ { "_oid": "NULL", "Status": { "_oid": "NULL" }, "_children": [ { "_oid": "Story:1210", "Status": { "_oid": "NULL" }, "Name": "Rollover pop-up window" } ] } ] ] ``` Assets whose relation is unset land in a group whose `_oid` is the string `"NULL"` — handle that bucket rather than assuming every group header is a real asset. **A grouping subquery.** Nest a `from`/`select` object to shape the group headers — for example, to carry each group's `Name`: ```json { "from": "Story", "select": ["Name"], "group": [{ "from": "Status", "select": ["Name"] }], "page": { "start": 0, "size": 50 } } ``` ```json title="Response shape (abridged)" [ [ { "_oid": "StoryStatus:133", "Name": "Future", "_children": ["..."] }, { "_oid": "StoryStatus:1082", "Name": "Done", "_children": ["..."] } ] ] ``` A dotted path such as `group: ["Status.Name"]` does **not** work — group by the relation itself, or by a grouping subquery selecting the attribute you wanted, not by an attribute reached through the relation. ## Common Queries ### Get Story by Number ```json { "from": "Story", "select": ["Number", "Name", "Status.Name"], "where": { "Number": "S-02574" } } ``` ### Get All Active Stories in a Scope ```json { "from": "Story", "select": ["Number", "Name", "Status.Name", "Owners.Name"], "where": { "Scope": "Scope:1338" }, "filter": ["AssetState='Active'"] } ``` ### Get Stories by Status ```json { "from": "Story", "select": ["Number", "Name", "ChangeDate"], "filter": ["Status.Name='Ready'", "Status.Name='Development'"], "sort": ["+ChangeDate"] } ``` ### Get Recent Defects ```json { "from": "Defect", "select": ["Number", "Name", "Priority.Name", "CreateDate"], "sort": ["-CreateDate"], "page": { "start": 0, "size": 20 } } ``` ## Code Examples ### cURL ```bash curl "${AGILITY_BASE_URL}/query.v1" \ -H "Authorization: Bearer ${AGILITY_ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "from": "Story", "select": ["Number", "Name", "Status.Name"], "where": {"Number": "S-02574"} }' ``` ### Python ```python import os import requests def query_story(number): payload = { "from": "Story", "select": ["Number", "Name", "Status.Name", "Scope.Name"], "where": {"Number": number} } response = requests.post( f"{os.environ['AGILITY_BASE_URL']}/query.v1", headers={ "Authorization": f"Bearer {os.environ['AGILITY_ACCESS_TOKEN']}", "Content-Type": "application/json" }, json=payload, timeout=30 ) response.raise_for_status() return response.json() # Usage result = query_story("S-02574") print(result) ``` ### TypeScript ```typescript interface QueryPayload { from: string; select: string[]; where?: Record; filter?: string[]; sort?: string[]; page?: { start: number; size: number }; } async function queryStories(baseUrl: string, token: string, payload: QueryPayload): Promise { const response = await fetch(`${baseUrl}/query.v1`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(`Query failed: ${response.status}`); } return response.json(); } // Usage const result = await queryStories(baseUrl, token, { from: 'Story', select: ['Number', 'Name', 'Status.Name'], where: { Number: 'S-02574' }, }); ``` ## Error Handling | Status Code | Meaning | Solution | | ----------- | ----------------------------------------------- | ----------------------------------------------- | | 400 | Invalid query syntax or missing required fields | Validate your query structure | | 401 | Authentication failed | Check your access token | | 404 | Asset not found | Verify the asset exists and you have permission | | 429 | Rate limit exceeded | Implement exponential backoff | | 500 | Unknown key in the request body — the response body names it: `"Invalid key at node(s): root->…"` | Remove or correct the offending key; this is a request defect, not a server fault, despite the status | | 500/503 | Server error | Retry with exponential backoff | ## Best Practices 1. **Include both object and nested properties** - Handles different response formats 2. **Filter by AssetState** - Exclude deleted assets: `AssetState!='Dead'` 3. **Use pagination** - Don't retrieve thousands of records at once 4. **Request only needed attributes** - Smaller payloads = faster responses 5. **Validate attributes** - Check [Meta API](/apis/meta-v1) before using custom fields 6. **Handle both response formats** - Normalize nested arrays and query objects ## Related Topics - [Query grammar](/reference/query-grammar) - The attribute-path and filter-expression vocabulary shared with `rest-1.v1`, plus the full comparison of the two surfaces - [Meta API](/apis/meta-v1) - Discover available attributes - [REST API](/apis/rest-1) - Create and update assets - [Payload Patterns](/guides/payload-patterns) - Best practices for payloads - [Code Examples](/examples/curl) - More query examples - [Official Query API Documentation](https://docs.digital.ai/agility/docs/developerlibrary/data-api) - [API Endpoints Overview](https://docs.digital.ai/agility/docs/developerlibrary/endpoints) --- # Webhooks (api/webhook) - URL: https://docs.digital.ai/agility-api/apis/webhooks - Markdown: https://docs.digital.ai/agility-api/apis/webhooks.md - Source: apis/webhooks/index.md - Description: Subscribe to asset created and changed events and have Agility push them to your system instead of polling. - Tags: api, webhooks, events, integration A **webhook subscription** tells Agility to send an HTTP request to a URL you control whenever something you care about happens. It is the push alternative to polling [`query.v1`](/apis/query-v1) on a timer. Subscriptions can be as broad or as narrow as you like: every Story status change in the instance, or only status changes on Stories in one project that are owned by one person. ## How It Fits Together A subscription has two halves: 1. **Where to deliver** — the `url`, an optional `authorizationHeader`, an optional `secret` for payload signing, and a `webhookId` you choose to identify it. 2. **What to deliver** — an `eventTypes` array. Each entry describes a class of change using the same vocabulary as a query: `from`, `attributes`, `filter`, `with`, `userContext` and `select`. When a matching change happens, Agility `POST`s a JSON body describing the event — who did it, what changed, and a projection of the asset shaped by your `select`. ## Operations | Operation | Endpoint | | ------------------------------------------------ | ------------------------------------- | | [Create a subscription](/apis/webhooks/create) | `POST ~/api/webhook` | | [Query subscriptions](/apis/webhooks/query) | `GET ~/api/webhook` | | [Update a subscription](/apis/webhooks/update) | `POST ~/api/webhook/:id` | | [Delete a subscription](/apis/webhooks/delete) | `POST ~/api/webhook/delete/:id` | | [Check delivery status](/apis/webhooks/status) | `GET ~/api/webhook/status/:id` | Supporting reference: - [Event types](/apis/webhooks/event-types) — how to describe what you want to be notified about - [Webhook payload](/apis/webhooks/payload) — what arrives at your endpoint, and how to verify it :::warning These are `POST` endpoints, including delete There is no `PUT`, `PATCH` or `DELETE` verb anywhere in this API. Update is a `POST` to the subscription URL, and **delete is a `POST` to `.../api/webhook/delete/:id`** — the id is in the path *after* the word `delete`. This is unusual, it is deliberate, and sending `DELETE ~/api/webhook/:id` will get you a `405`. See [delete](/apis/webhooks/delete). ::: ## Permissions Every endpoint in this API requires the **`ManageConfig`** privilege — the same administrative privilege that governs instance configuration. Webhook subscriptions are an instance-level integration surface, not a per-user one. ## Instance Limits and Configuration | Limit | Default | Effect | | ------------------------------------- | --------- | -------------------------------------------------------------------- | | Maximum **enabled** subscriptions | unlimited | Creating past the limit fails. | | Delivery retry attempts | `5` | Attempts per delivery — see [retries and automatic disabling](#retries-and-automatic-disabling). | These are instance configuration, adjustable by an administrator on on-premise installations. :::note The "limit of 5 subscriptions" is out of date The original reference stated that "in the first release of webhook subscriptions, your instance can have up to 5 enabled webhook subscriptions at a time". That restriction has since been lifted: `MaxActiveSubscriptions` now defaults to unlimited and the cap only applies where an administrator has explicitly configured one. If you do hit a configured cap, creation fails with a message naming the limit. Check the setting rather than assuming the number is 5. The unrelated `5` that *is* still real is the retry count. ::: Webhooks can also be disabled instance-wide. When they are, every endpoint in this section returns `404` rather than an explanatory error — a blanket `404` from a correctly-formed request usually means the feature is switched off, not that your subscription is missing. ## Delivery Semantics Understand these before you build against webhooks: - **Non-`200` means failure.** Responding with anything other than `200 OK` counts as a failed delivery. Return `200` as soon as you have durably accepted the payload; do your processing afterwards. - **Repeated failure eventually disables the subscription** — through the retry and probation model [below](#retries-and-automatic-disabling). If a webhook has "stopped working", check its [`retryCount`](/apis/webhooks/query) and its [delivery receipts](/apis/webhooks/status) first. - **Order is not guaranteed.** Events may arrive out of order. Every event carries a `sequenceId` so you can reorder them yourself. Do not infer ordering from arrival time. - **Delivery is at-least-once in effect.** A retry after a response you actually processed will redeliver the event. Make your handler idempotent, keyed on `webhookId` plus `sequenceId`. - **Your endpoint must be reachable from the Agility instance.** For cloud-hosted instances that means publicly routable, or reachable through whatever egress path your instance is configured with. ## Retries and Automatic Disabling Two separate mechanisms, often conflated: **Retries happen per delivery.** Each delivery is attempted up to **5 times** (default), with exponential backoff starting at **5 seconds** — so the gaps run 5, 10, 20, 40, 80 seconds. A successful delivery resets the retry count to zero. **Disabling happens on probation.** When a delivery exhausts its retries: 1. The **first** exhausted delivery puts the subscription on **probation** (default **12 hours**) and sends a failure-warning email to the owner. 2. A **successful** delivery during probation clears it — with a recovery email. Nothing was lost but the failed deliveries themselves. 3. A delivery that exhausts its retries **after the probation window has elapsed** — meaning the endpoint has been failing for the whole window — **disables the subscription**, with a final notification email. A disabled subscription stops producing [receipts](/apis/webhooks/status); fix the receiver, then re-enable via [update](/apis/webhooks/update). The retry count, backoff quantum and probation length are configurable on on-premise instances — ask your administrator if your instance behaves differently from the defaults above. ## Related Topics - [Event types](/apis/webhooks/event-types) - [Webhook payload](/apis/webhooks/payload) - [Activity stream](/apis/activity-stream) — the pull equivalent, for reading history - [Query API (query.v1)](/apis/query-v1) — the `select` and `filter` vocabulary - [API conventions](/apis/conventions) --- # Event Types - URL: https://docs.digital.ai/agility-api/apis/webhooks/event-types - Markdown: https://docs.digital.ai/agility-api/apis/webhooks/event-types.md - Source: apis/webhooks/event-types.md - Description: Describe exactly which asset changes should fire a webhook using type, from, attributes, filter, with, userContext and select. - Tags: api, webhooks, events, filter The `eventTypes` array on a subscription is where you say what you actually want to hear about. Each entry is an independent rule; a subscription fires if **any** of its event types matches. ## The Two Event Types Agility records every asset creation and every asset change. Each event type entry declares which of the two it is watching: | `type` | Fires when | | --------------- | --------------------------------- | | `AssetCreated` | An asset of `from` is created. | | `AssetChanged` | An asset of `from` is modified. | These are the only two accepted values. Anything else is rejected at subscription-create time with a message naming both. ## Fields | Field | Type | Required | Description | | ------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------- | | `type` | string | yes | `AssetCreated` or `AssetChanged`. | | `from` | string | yes | The asset type to watch, e.g. `Story`. Use the system name — see [terminology](/apis/conventions/terminology#asset-type). | | `attributes` | string[] | no | Restrict to changes touching these attributes. Omit to fire on **any** change. | | `filter` | string[] | no | Filter expressions the asset must satisfy. The full [filter language](/reference/query-grammar/filter-expressions) — operators, value lists, unary existence and grouping all work here. | | `with` | object | no | [Filter context](/reference/query-grammar/filter-context) — variable bindings substituted into `filter`. | | `userContext` | string | no | Oid token of a member whose visibility the event is evaluated under. | | `select` | array | no | [Attribute paths](/reference/query-grammar/attribute-paths) to project into the delivered payload's `snapshot`. | ## Building up an Event Type ### Every Change to a Story ```json { "type": "AssetChanged", "from": "Story" } ``` ### Only When Status Changes `attributes` narrows the rule to changes that touch the listed attributes. Without it you are notified of *any* change to the asset, which on a busy instance is a lot of traffic. ```json { "type": "AssetChanged", "from": "Story", "attributes": ["Status"] } ``` ### Only Stories in One Project `filter` takes the same filter expressions as a query, and `with` binds the variables they reference. Keeping the value in `with` rather than inlining it makes the subscription easy to retarget later. ```json { "type": "AssetChanged", "from": "Story", "attributes": ["Status"], "filter": ["Scope=$Scope"], "with": { "$Scope": "Scope:0" } } ``` See the [query grammar reference](/reference/query-grammar) for the full filter syntax. ### Only What a Particular Member Can See Most assets in Agility are secured by their relationship to a Scope (project). Without `userContext`, an event type sees **everything** — assets in every project, regardless of who can normally view them. `userContext` runs the evaluation as a specific member, so only changes visible to that member produce events: ```json { "type": "AssetChanged", "from": "Story", "attributes": ["Status"], "userContext": "Member:1002" } ``` :::warning `userContext` is a security control, not a convenience An event type without `userContext` will happily deliver data from projects the subscription's creator could not otherwise read, to whatever URL you named. If the receiving system is not trusted with instance-wide data, set `userContext` to a member with appropriately limited visibility. Note also that the original reference material contained the literal value `"Memeber:1002"` in two of its examples — a misspelling of `Member`. An unresolvable Oid token here does not fail open in a useful way; use the correct spelling. ::: ### Include Asset Details in the Delivery By default the delivered payload identifies the asset by Oid and lists what changed, but tells you nothing else about it. `select` projects additional attributes into the payload's `snapshot`, using the same projection syntax as a query — including dot traversal across relations: ```json { "type": "AssetChanged", "from": "Story", "attributes": ["Status"], "filter": ["Scope=$Scope"], "with": { "$Scope": "Scope:0" }, "select": ["Name", "Owners.Name"] } ``` The `snapshot` in the delivered payload then carries `Name` and each owner's `Name`, in the same shape a [`query.v1`](/apis/query-v1) result would have. This is what lets a handler act on an event without calling back into the API for context — worth doing, because it removes a round-trip and avoids a race where the asset changes again before you read it. :::note The field is `attributes`, plural A common mistake is sending `attribute` (singular). Because `api/webhook` rejects unknown keys instead of ignoring them, that produces a `400` rather than a silent misconfiguration. See [unknown keys are rejected](/apis/webhooks/create) on the create page for the mechanism. ::: ## A Complete `eventTypes` Array Event types compose. This subscription fires on new Defects anywhere, and on Story status changes in one project: ```json { "eventTypes": [ { "type": "AssetCreated", "from": "Defect", "select": ["Name", "Number", "Priority"] }, { "type": "AssetChanged", "from": "Story", "attributes": ["Status"], "filter": ["Scope=$Scope"], "with": { "$Scope": "Scope:0" }, "select": ["Name", "Status.Name", "Owners.Name"] } ] } ``` Each delivered event names the `eventType` that produced it, so a handler can dispatch on it. ## Related Topics - [Create a subscription](/apis/webhooks/create) - [Webhook payload](/apis/webhooks/payload) — what `select` produces - [Query grammar reference](/reference/query-grammar) — filter and projection syntax - [Attribute definition syntax](/apis/conventions/attribute-definitions) --- # Webhook Payload - URL: https://docs.digital.ai/agility-api/apis/webhooks/payload - Markdown: https://docs.digital.ai/agility-api/apis/webhooks/payload.md - Source: apis/webhooks/payload.md - Description: The JSON Agility POSTs to your endpoint, the headers it sends, and how to verify the HMAC signature. - Tags: api, webhooks, events, security This is what arrives at the `url` on your subscription. ## Request Agility sends an HTTP `POST` with a JSON body. ### Headers Sent to Your Endpoint | Header | Value | Sent when | | ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ | | `Content-Type` | `application/json` | Always. | | `Authorization` | Verbatim value of the subscription's `authorizationHeader` | Only if the subscription has a non-empty one. | | `X-SIGNATURE-HMAC-SHA512` | Base64 HMAC-SHA512 of the raw body, keyed with your `secret` | Only if the subscription has a non-empty `secret`. | | `X-SIGNATURE` | Legacy signature, computed differently | Only where the instance has legacy signatures enabled. | No fixed `User-Agent` is set, so do not filter on one. ## Body ```json { "events": [ { "webhookId": "YYY", "sequenceId": 1, "eventType": "AssetChanged", "timestamp": "UTC timestamp", "instigator": { "_oid": "Member:20", "href": "https://V1Host/V1Instance/assetdetail.v1?oid=Member:20", "name": "Administrator", "nickName": "Admin", "email": "admin@admin.com", "role": "Role:2", "avatar": "https://V1Host/V1Instance/Image.mvc/Show?imageOid=Image:192923" }, "targetAsset": { "_oid": "Story:123:456", "assetType": "Story", "href": "https://V1Host/V1Instance/assetdetail.v1?oid=Story:123" }, "changes": [ { "name": "Name", "old": "Original Name", "new": "New Name", "act": "Set" }, { "name": "Owners", "new": "Member:20", "act": "Added" } ], "snapshot": [ { "_oid": "Story:123", "Owners.Name": ["Administrator"], "Owners.ID": [{ "_oid": "Member:20" }], "SubsAndMe.Owners.Name": ["Administrator"] } ] } ] } ``` The `href` values are [asset permalinks](/apis/asset-permalinks) — `assetdetail.v1` URLs that open the asset in the product UI, built for the humans on the receiving end of a delivery rather than for programs. :::note `events` is an array A single delivery can carry more than one event. Iterate; do not read `events[0]` and stop. ::: ### Event Fields | Field | Type | Description | | ------------- | ------- | ------------------------------------------------------------------------------------------- | | `webhookId` | string | The identifier **you** chose when creating the subscription. Use it to route the delivery. | | `sequenceId` | integer | Monotonic sequence number. Use it to restore ordering. | | `eventType` | string | `AssetCreated` or `AssetChanged` — which event type rule fired. | | `timestamp` | string | UTC timestamp of the change. | | `instigator` | object | The member who made the change. | | `targetAsset` | object | The asset that was created or changed. | | `changes` | array | What actually changed. Empty or absent for `AssetCreated`. | | `snapshot` | array | Projection of the asset, shaped by the event type's `select`. | ### `instigator` | Field | Description | | ---------- | ----------------------------------------------------------------- | | `_oid` | Oid token of the member, e.g. `Member:20`. | | `href` | Deep link to the member's detail page in the Agility UI. | | `name` | Full name. | | `nickName` | Short name. | | `email` | Email address. | | `role` | Oid token of the member's role, e.g. `Role:2`. | | `avatar` | URL to the avatar image. | ### `targetAsset` | Field | Description | | ----------- | ------------------------------------------------------------------------------------------ | | `_oid` | Oid token of the asset — often **including a moment**, e.g. `Story:123:456`. | | `assetType` | The asset type system name. | | `href` | Deep link to the asset in the Agility UI. | :::warning `targetAsset._oid` may carry a moment `Story:123:456` is Story 123 **at version 456**, not Story 123. If you use this value as a correlation key or pass it back into the API expecting current data, strip the third segment first. Note that `href` and `snapshot[]._oid` in the same payload use the momentless form (`Story:123`) — the two are not interchangeable. ::: ### `changes[]` One entry per attribute affected. | Field | Description | | ------ | ----------------------------------------------------------------------- | | `name` | The attribute definition name. | | `old` | Previous value. Absent where there was none — including on relation adds. | | `new` | New value. | | `act` | What was done: `Set`, `Added`, `Removed`. | Scalar attributes are `Set` and carry both `old` and `new`. Multi-value relations are `Added` or `Removed` and carry only `new` — the Oid token of the value added or removed, not the whole resulting collection. ### `snapshot[]` The projection requested by the event type's [`select`](/apis/webhooks/event-types#include-asset-details-in-the-delivery), in the same shape a [`query.v1`](/apis/query-v1) result would take: `_oid` plus one key per selected path. Multi-value projections are arrays, and relation projections without a further attribute come back as `{ "_oid": ... }` objects. With no `select` on the event type, `snapshot` carries little more than the `_oid`. ## Verifying the Payload If you set a `secret` on the subscription, every delivery carries `X-SIGNATURE-HMAC-SHA512`: the Base64-encoded HMAC-SHA512 of the **raw UTF-8 request body**, keyed with your secret. :::danger Verify against the raw body Compute the HMAC over the exact bytes received, before any JSON parsing or re-serialization. Parsing and re-encoding will change whitespace and key order and the signature will not match. Capture the raw body first, verify, then parse. ::: ```python title="Verifying a delivery in Python" import base64 import hashlib import hmac def is_signature_valid(raw_body: bytes, secret: str, header_value: str) -> bool: expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha512).digest() return hmac.compare_digest( base64.b64encode(expected).decode("ascii"), header_value, ) ``` Always compare in constant time, as above. :::note `secret` was undocumented Neither the `secret` subscription field nor the signature headers appeared in the published reference material. `secret` is write-only from the API's point of view: it is accepted on [create](/apis/webhooks/create) and [update](/apis/webhooks/update), but it is never returned by [query](/apis/webhooks/query) or included in [delivery receipts](/apis/webhooks/status). Store your copy of it when you set it. ::: ### `authorizationHeader` Versus `secret` They solve different problems and are worth using together: | Field | Direction | Purpose | | --------------------- | ------------------------------------ | ----------------------------------------------------------------------- | | `authorizationHeader` | Agility proves itself **to you** | Sent verbatim as the `Authorization` header so your endpoint can authenticate the caller. | | `secret` | Agility proves the **payload** | Signs the body, so you know it was not altered and really came from your instance. | A static `Authorization` value proves the caller knows a shared token, but does nothing about payload integrity, and it is replayable if intercepted. The HMAC signature covers the body. ## Responding Return **`200 OK`**. Any other status counts as a failed delivery, and consecutive failures take the subscription out of service — see [delivery semantics](/apis/webhooks#delivery-semantics). Acknowledge quickly and process asynchronously: enqueue the payload, return `200`, and do the work outside the request. A slow handler produces timeouts, which count as failures. Make handlers **idempotent**. Deduplicate on `webhookId` plus `sequenceId`; a retry after a response you actually processed will redeliver the event. ## Related Topics - [Event types](/apis/webhooks/event-types) — controlling what gets delivered - [Delivery status](/apis/webhooks/status) — what your endpoint sent back - [Create a subscription](/apis/webhooks/create) --- # Create a Subscription - URL: https://docs.digital.ai/agility-api/apis/webhooks/create - Markdown: https://docs.digital.ai/agility-api/apis/webhooks/create.md - Source: apis/webhooks/create.md - Description: Create a webhook subscription with POST ~/api/webhook. - Tags: api, webhooks, create ## Request ```text POST ~/api/webhook ``` ### Payload ```json { "url": "https://externalsystem.com", "authorizationHeader": "Optional HTTP Authorization Header to send with each webhook", "secret": "Optional shared secret used to sign each webhook", "webhookId": "Webhook identifier", "enabled": true, "description": "Short description of the webhook subscription", "eventTypes": [] } ``` | Field | Type | Required | Description | | --------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------- | | `url` | string | yes | Where deliveries are `POST`ed. Must be reachable from the Agility instance. | | `webhookId` | string | yes | **Your** identifier for this subscription. Echoed in every delivery. Make it meaningful and unique. | | `eventTypes` | array | no | What to be notified about. See [event types](/apis/webhooks/event-types). Omitting it is accepted — validation is skipped when the key is absent — but a subscription with no event types is not useful. | | `notificationEmails` | array | no | Addresses notified about delivery problems for this subscription. | | `enabled` | boolean | no | Whether the subscription is live. Create disabled to stage a configuration. | | `description` | string | no | Free text: where this goes and why. Worth filling in — it is what a future administrator will read. | | `authorizationHeader` | string | no | Sent verbatim as the `Authorization` header on every delivery. | | `secret` | string | no | Shared secret used to HMAC-sign each delivery. See [verifying the payload](/apis/webhooks/payload#verifying-the-payload). | :::info Unknown keys are rejected — this endpoint is stricter than most `api/webhook` validates the request body strictly and rejects **any property that is not a member of the model**, answering `400` with `{ "errorMessage": … }`. This applies to both create and update, and it is worth knowing because it is the opposite of how several other Agility surfaces behave — the Bulk API, for instance, tolerates keys it does not recognise. Do not assume a payload that works elsewhere will be accepted here. ::: ### Example ```bash title="Request" curl -X POST "https://{host}/{instance}/api/webhook" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "url": "https://externalsystem.example.com/hooks/agility", "webhookId": "story-status-to-slack", "description": "Notifies #delivery when a Story status changes in the Sandbox project", "enabled": true, "authorizationHeader": "Bearer s3cr3t-token-the-receiver-checks", "secret": "a-long-random-string-used-for-hmac", "eventTypes": [ { "type": "AssetChanged", "from": "Story", "attributes": ["Status"], "filter": ["Scope=$Scope"], "with": { "$Scope": "Scope:0" }, "select": ["Name", "Number", "Status.Name", "Owners.Name"] } ] }' ``` ## Response ```json title="200 OK" { "id": "336d997f-d9fa-4612-80e8-9f41a4b41352", "webhookId": "Webhook identifier", "url": "https://externalsystem.com", "description": "Short description of the webhook subscription", "enabled": true, "eventTypes": [], "createdAt": "2018-09-27T20:28:41.3829451Z", "updatedAt": "2018-09-27T20:28:41.3829451Z", "createdBy": "Member:20" } ``` | Field | Description | | ---------------- | ------------------------------------------------------------------------------------------------- | | `id` | **Server-assigned UUID.** This — not `webhookId` — is what update, delete and status take. | | `webhookId` | The identifier you supplied. | | `createdBy` | Oid token of the member who created the subscription. | The create response does not carry delivery-health fields — a subscription that has never fired has no delivery history. For `retryCount` and `lastResponseAt`, use [Query subscriptions](/apis/webhooks/query) or [Delivery status](/apis/webhooks/status). :::danger Two identifiers, and only one works in URLs `id` is the server-generated UUID and is the **only** thing the [update](/apis/webhooks/update), [delete](/apis/webhooks/delete) and [status](/apis/webhooks/status) endpoints accept in their path. `webhookId` is your own label, returned in deliveries so your handler can route them. Store the `id` from this response. If you lose it, recover it with [query](/apis/webhooks/query) and match on your `webhookId`. ::: Note that `authorizationHeader` and `secret` are **not** echoed back. They are write-only. ## After Creating 1. Confirm it is live with [query](/apis/webhooks/query). 2. Make a change that should match, then check [delivery status](/apis/webhooks/status) to see what your endpoint actually returned. Creating with `"enabled": false` first, then enabling via [update](/apis/webhooks/update) once the receiver is deployed, avoids burning retry attempts against an endpoint that is not up yet. ## Related Topics - [Event types](/apis/webhooks/event-types) - [Webhook payload](/apis/webhooks/payload) - [Query subscriptions](/apis/webhooks/query) - [Webhooks overview](/apis/webhooks) — permissions and instance limits --- # Query Subscriptions - URL: https://docs.digital.ai/agility-api/apis/webhooks/query - Markdown: https://docs.digital.ai/agility-api/apis/webhooks/query.md - Source: apis/webhooks/query.md - Description: List webhook subscriptions with paging, or retrieve one by id. - Tags: api, webhooks, query ## List Subscriptions ```text GET ~/api/webhook?page=pageSize,pageStart ``` | Parameter | In | Required | Description | | --------- | ----- | -------- | ------------------------------------------------------------------------ | | `page` | query | no | `pageSize,pageStart` — **size first, then start**. Defaults to `25,0`. | :::warning Size comes first The `page` parameter is a comma-separated pair in the order **size, start** — `page=10,20` means "10 per page, starting at offset 20", not "page 10 of size 20". Both values must be present for either to take effect; a single value is ignored and the defaults apply. ::: ```bash title="Request" curl "https://{host}/{instance}/api/webhook?page=25,0" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ### Response ```json title="200 OK" { "total": 1, "pageSize": 25, "pageStart": 0, "_type": "WebhookSubscription", "results": [ { "id": "336d997f-d9fa-4612-80e8-9f41a4b41352", "webhookId": "Webhook identifier", "url": "https://matchedUrl.com", "description": "Short description of the webhook subscription", "enabled": true, "eventTypes": [], "lastResponseAt": "0001-01-01T00:00:00Z", "retryCount": 0, "createdAt": "2018-09-27T20:28:41.3829451Z", "updatedAt": "2018-09-27T20:28:41.3829451Z", "createdBy": "Member:20" } ] } ``` | Field | Description | | ----------- | ------------------------------------------------------- | | `total` | Total subscriptions, not the number in this page. | | `pageSize` | The effective page size. | | `pageStart` | The effective offset. | | `_type` | Always `WebhookSubscription`. | | `results` | The subscriptions in this page. | Page through by advancing `pageStart` by `pageSize` until you have seen `total` results. ## Retrieve One Subscription ```text GET ~/api/webhook/:id ``` Where `:id` is the **server-assigned UUID** from [create](/apis/webhooks/create#response), not your `webhookId`. ```bash title="Request" curl "https://{host}/{instance}/api/webhook/336d997f-d9fa-4612-80e8-9f41a4b41352" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` The response is a single subscription object, in the same shape as one `results` entry above. An unknown id returns `404` with an explanatory body: ```json title="404 Not Found" { "errorMessage": "Webhook subscription with id 336d997f-d9fa-4612-80e8-9f41a4b41352 was not found" } ``` :::note Undocumented in the original reference `GET ~/api/webhook/:id` did not appear in the published reference material at all. ::: ## Fields Worth Watching Two fields make this endpoint the first place to look when a webhook has "stopped working": - **`retryCount`** — consecutive failed deliveries. Non-zero means deliveries are failing right now. When it reaches the instance's retry limit (default `5`), the subscription is taken out of service. - **`lastResponseAt`** — when a delivery attempt last got a response. Still `0001-01-01T00:00:00Z` after events should have fired means nothing has ever been delivered — suspect the event type definition or `enabled`, not the receiver. For the detail behind those numbers, use [delivery status](/apis/webhooks/status). ## Notes - `authorizationHeader` and `secret` are never returned by this endpoint. They are write-only. - The list is **not** filtered to subscriptions you created; it is instance-wide, consistent with the `ManageConfig` privilege the API requires. - Recover a lost `id` by listing and matching on your own `webhookId`. ## Related Topics - [Create a subscription](/apis/webhooks/create) - [Delivery status](/apis/webhooks/status) - [Update a subscription](/apis/webhooks/update) --- # Update a Subscription - URL: https://docs.digital.ai/agility-api/apis/webhooks/update - Markdown: https://docs.digital.ai/agility-api/apis/webhooks/update.md - Source: apis/webhooks/update.md - Description: Change a webhook subscription with POST ~/api/webhook/:id. - Tags: api, webhooks, update ## Request ```text POST ~/api/webhook/:id ``` `:id` is the **server-assigned UUID** returned by [create](/apis/webhooks/create#response) — not your `webhookId`. :::note It is a `POST`, not a `PUT` or `PATCH` These APIs accept only `GET` and `POST`. Sending `PUT` or `PATCH` returns `405`. See [errors](/apis/conventions/errors). ::: ### Payload ```json { "url": "https://newurl.com", "enabled": false, "description": "Updated description of the webhook subscription", "eventTypes": [] } ``` The same fields accepted by [create](/apis/webhooks/create#payload) apply here, including `authorizationHeader` and `secret`. ```bash title="Disabling a subscription" curl -X POST "https://{host}/{instance}/api/webhook/336d997f-d9fa-4612-80e8-9f41a4b41352" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "enabled": false }' ``` :::warning Send the whole subscription, not just the change Update replaces the subscription's configuration rather than merging into it. In particular, sending a payload without `eventTypes` can leave the subscription with no event rules at all — enabled, healthy-looking, and silent. The safe pattern is read-modify-write: [`GET ~/api/webhook/:id`](/apis/webhooks/query#retrieve-one-subscription), change the fields you want, and post the complete object back. Note the corollary for the write-only fields: because `authorizationHeader` and `secret` are never returned by a read, a naive read-modify-write **clears them**. Re-supply them from your own copy on every update. ::: ## Response The full updated subscription, in the same shape as the [create response](/apis/webhooks/create#response): ```json title="200 OK" { "id": "336d997f-d9fa-4612-80e8-9f41a4b41352", "webhookId": "Webhook identifier", "url": "https://newurl.com", "description": "Updated description of the webhook subscription", "enabled": false, "eventTypes": [], "lastResponseAt": "0001-01-01T00:00:00Z", "retryCount": 0, "createdAt": "2018-09-27T20:28:41.3829451Z", "updatedAt": "2018-09-27T20:28:41.3829451Z", "createdBy": "Member:20" } ``` `updatedAt` advances; `createdAt` and `createdBy` do not. ## Re-Enabling a Disabled Subscription A subscription taken out of service by repeated delivery failures is re-enabled the same way — `POST` with `"enabled": true`. Fix the receiving end first. Check [delivery status](/apis/webhooks/status) to see what your endpoint actually returned, correct that, and only then re-enable. Re-enabling against a still- broken endpoint simply burns the retry budget again. ## Related Topics - [Create a subscription](/apis/webhooks/create) — the full field reference - [Query subscriptions](/apis/webhooks/query) — read before you write - [Delivery status](/apis/webhooks/status) - [Delete a subscription](/apis/webhooks/delete) --- # Delete a Subscription - URL: https://docs.digital.ai/agility-api/apis/webhooks/delete - Markdown: https://docs.digital.ai/agility-api/apis/webhooks/delete.md - Source: apis/webhooks/delete.md - Description: Delete a webhook subscription with POST ~/api/webhook/delete/:id — note the verb and the path shape. - Tags: api, webhooks, delete ## Request ```text POST ~/api/webhook/delete/:id ``` :::danger This is a `POST`, and `delete` comes before the id Two things are surprising here, and both are real: 1. **The verb is `POST`, not `DELETE`.** These APIs accept only `GET` and `POST`; sending `DELETE ~/api/webhook/:id` returns `405 Method Not Allowed`. 2. **`delete` is a path segment before the id.** It is `/api/webhook/delete/:id`, not `/api/webhook/:id/delete`. Getting the path shape wrong is worse than getting the verb wrong: `POST ~/api/webhook/:id` is the **[update](/apis/webhooks/update) endpoint**, so a mis-formed delete will not fail — it will succeed as an update, quietly overwriting the subscription with whatever body you sent (or with an empty configuration if you sent none). ::: `:id` is the **server-assigned UUID** from [create](/apis/webhooks/create#response), not your `webhookId`. ```bash title="Request" curl -X POST "https://{host}/{instance}/api/webhook/delete/336d997f-d9fa-4612-80e8-9f41a4b41352" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` No request body is required. ## Response The deleted subscription is returned, so you have a record of what was removed: ```json title="200 OK" { "id": "336d997f-d9fa-4612-80e8-9f41a4b41352", "webhookId": "Webhook identifier", "url": "https://externalsystem.com", "description": "Short description of the webhook subscription", "enabled": true, "eventTypes": [], "lastResponseAt": "0001-01-01T00:00:00Z", "retryCount": 0, "createdAt": "2018-09-27T20:28:41.3829451Z", "updatedAt": "2018-09-27T20:28:41.3829451Z", "createdBy": "Member:20" } ``` Keep this response if you might need to recreate the subscription. Note that `authorizationHeader` and `secret` are **not** included — they are write-only, so restoring a deleted subscription requires your own copy of those values. ## Delete Versus Disable Deleting is usually the wrong tool for "stop these notifications for now". Prefer [`"enabled": false`](/apis/webhooks/update) — it preserves the id, the event type definitions and the credentials, and it is a one-field change to reverse. Delete when the integration is genuinely retired. ## Deleting Every Subscription ```text POST ~/api/webhook/delete ``` The same route with **no id** deletes subscriptions in bulk. :::danger This deletes every subscription in the instance `POST ~/api/webhook/delete` marks **all** webhook subscriptions in the instance as deleted. It is not scoped to the subscriptions you created, and it is not scoped to a project — it iterates every subscription in the registry. Every integration in the instance stops receiving events, including ones owned by other teams. There is no confirmation step and no bulk undo. Note how close it is to the single-subscription form: omitting the id — a trailing-slash bug, an empty variable in a URL template — turns "delete this one" into "delete them all". Guard against an empty id in client code before the request is built. This route was undocumented in the published reference material. ::: If you need it, capture the full list first with [query](/apis/webhooks/query) so you can reconstruct. ## Related Topics - [Update a subscription](/apis/webhooks/update) — disable rather than delete - [Query subscriptions](/apis/webhooks/query) - [Errors](/apis/conventions/errors) — why `DELETE` returns `405` --- # Delivery Status - URL: https://docs.digital.ai/agility-api/apis/webhooks/status - Markdown: https://docs.digital.ai/agility-api/apis/webhooks/status.md - Source: apis/webhooks/status.md - Description: Inspect the 20 most recent delivery receipts for a subscription to troubleshoot webhooks that are not arriving. - Tags: api, webhooks, troubleshooting Retrieves the **20 most recent delivery receipts** for a subscription. This is the troubleshooting endpoint: it tells you whether webhooks are being sent, whether they are being received, and exactly what the external system said back. ## Request ```text GET ~/api/webhook/status/:id ``` `:id` is the **server-assigned UUID** from [create](/apis/webhooks/create#response), not your `webhookId`. ```bash title="Request" curl "https://{host}/{instance}/api/webhook/status/b3ff4f99-1436-4989-96a5-cb43341fc9e4" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ## Response A JSON array of receipts, most recent first. ```json title="200 OK" [ { "subscriptionId": "b3ff4f99-1436-4989-96a5-cb43341fc9e4", "hasAuthorizationHeader": true, "url": "https://externalsystem.com", "response": "Optional Response Information", "headers": { "transfer-Encoding": "chunked", "connection": "keep-alive", "x-Request-Id": "837ea952-2c56-4470-aa89-3f996ad1b65c", "x-Token-Id": "46054f26-412f-4772-a1d6-48648421fbc3", "x-RateLimit-Limit": "30", "x-RateLimit-Remaining": "26", "cache-Control": "no-cache", "content-Type": "text/plain; charset=UTF-8", "date": "Fri, 07 Dec 2018 19:54:53 GMT", "server": "nginx/1.10.3" }, "status": 500, "timeStamp": "2018-12-07T19:54:09.59Z", "retryCount": 3, "wasReceived": false } ] ``` | Field | Type | Description | | ------------------------ | ------- | ---------------------------------------------------------------------------------------------- | | `subscriptionId` | string | UUID of the subscription this receipt belongs to. | | `hasAuthorizationHeader` | boolean | Whether an `Authorization` header was sent. The **value is never exposed**, only its presence. | | `url` | string | The URL the delivery was attempted against. | | `response` | string | The **body your endpoint returned**. Often the most useful field here. | | `headers` | object | The response headers your endpoint returned. | | `status` | integer | The HTTP status code your endpoint returned. | | `timeStamp` | string | UTC timestamp of the delivery attempt. | | `retryCount` | integer | How many consecutive failures had occurred at this point. | | `wasReceived` | boolean | Whether the delivery was considered successful — `true` only for `200 OK`. | The `secret` and the `authorizationHeader` value are deliberately excluded from receipts. ## Reading a Receipt The example above is a failure: `status` is `500`, `wasReceived` is `false`, and `retryCount` has climbed to `3`. Two more and the subscription is taken out of service. | Symptom | Likely cause | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | **No receipts at all** | Nothing was ever sent. The subscription is disabled, or its [event types](/apis/webhooks/event-types) match nothing. | | `status` `401` or `403` | Your endpoint rejected the caller. Check `hasAuthorizationHeader` — see below. | | `hasAuthorizationHeader` is `false` unexpectedly | The header was never stored. Almost always the `authroizationHeader` typo — see [create](/apis/webhooks/create). | | `status` `404` | The `url` is wrong, or the route moved. | | `status` `500` | Your handler threw. `response` usually carries the detail. | | `status` `0`, or receipts absent while events fire | The request never completed — DNS, TLS, firewall or timeout. Check the instance can reach `url` at all. | | `wasReceived` `false` with a `2xx` that is not `200` | Only `200` counts. Return `200`, not `201` or `204`. | | Signature check failing on your side | You are hashing a re-serialized body. See [verifying the payload](/apis/webhooks/payload#verifying-the-payload). | :::note `hasAuthorizationHeader` is the fastest typo check If your endpoint is returning `401` and this field is `false` on every receipt, the subscription has no stored `Authorization` value — regardless of what you thought you sent. That's the signature of an `authroizationHeader` misspelling in the create request. Fix it with [update](/apis/webhooks/update), then re-enable if the subscription was disabled. ::: ## Notes - Only the **20 most recent** attempts are retained. On a high-volume subscription the window is short, so investigate promptly. - Receipts record delivery attempts, not events. An event that matched nothing produces no receipt. - A subscription disabled by repeated failure stops producing receipts, so the last 20 will be the failures that disabled it. ## Related Topics - [Query subscriptions](/apis/webhooks/query) — `retryCount` and `lastResponseAt` at a glance - [Update a subscription](/apis/webhooks/update) — fixing and re-enabling - [Webhook payload](/apis/webhooks/payload) — what to return, and how to verify - [Webhooks overview](/apis/webhooks#delivery-semantics) --- # Tags (api/tags) - URL: https://docs.digital.ai/agility-api/apis/tags - Markdown: https://docs.digital.ai/agility-api/apis/tags.md - Source: apis/tags/index.md - Description: Query, search, merge and remove asset tags instance-wide through ~/api/tags. - Tags: api, tags `~/api/tags` is an **instance-wide view of the tag vocabulary**. It answers questions that cannot be answered by querying assets one at a time: which tags exist, how widely each is used, and how to clean the vocabulary up by merging near-duplicates or removing tags outright. ## What Tags Are Tags are free-text values that live on assets. Critically, **a tag has no independent existence** — it exists only in the context of the assets it has been applied to. There is no "tag asset" you can create, and no tag object to delete. Removing a tag from the last asset carrying it removes it from the system. The consequence is a clean division of labour: | To do this | Use this | | ----------------------------------------- | ----------------------------------------------------------- | | Add or remove a tag **on a single asset** | [`rest-1.v1/Data`](/apis/rest-1) or [`~/api/asset`](/apis/asset-bulk), writing the `TaggedWith` attribute | | See the tag vocabulary **across the instance** | `~/api/tags` — this section | | Rename a tag everywhere | `~/api/tags/command/merge` — [merge](/apis/tags/merge) | | Purge a tag everywhere | `~/api/tags/command/remove` — [remove](/apis/tags/remove) | ## The `TaggedWith` Attribute Tags are surfaced on assets through the `TaggedWith` attribute definition. It is defined on `BaseAsset`, so it is inherited broadly, but it is exposed on these asset types in particular: - `Story` - `Defect` - `Task` - `Test` - `TestSet` - `Epic` (Portfolio Item) - `RegressionTest` - `RegressionSuite` :::note This list is not exhaustive `TaggedWith` is inherited from `BaseAsset`, and additional types expose it. Rather than trust a static list, ask the instance you are actually working against: ```bash curl "https://{host}/{instance}/meta.v1/Story?xsl=api.xsl" \ -H "Accept: application/json" ``` See [Metadata API (meta.v1)](/apis/meta-v1). ::: ## Operations | Operation | Endpoint | | -------------------------------------------- | ----------------------------------------------- | | [Query a single tag](/apis/tags/query) | `GET ~/api/tags?tag=` | | [Query all tags](/apis/tags/query-all) | `GET ~/api/tags/all` | | [Search tags](/apis/tags/search) | `GET ~/api/tags/command/search?term=` | | [Merge tags](/apis/tags/merge) | `POST ~/api/tags/command/merge` | | [Remove tags](/apis/tags/remove) | `POST ~/api/tags/command/remove` | ## Scope Visibility Every count this API returns is filtered twice. `count` is the total number of usages of a tag in the system; `visibleCount` is the number of those usages on assets the **calling member** can actually see. A member restricted to one project will see a small `visibleCount` against a large `count`. The command endpoints (`merge`, `remove`) honour the same boundary: they act only on assets the caller can see and edit. A merge run by a project-scoped member will leave the tag intact elsewhere in the instance, and the response will not tell you that happened. Run vocabulary cleanup as a member with instance-wide visibility. ## Permissions `merge` and `remove` require the **`ManageTags`** privilege. :::note `ManageTags` — not Project Admin access — is what the server enforces. The two often coincide in practice because `ManageTags` is granted to administrative roles, but they are not the same thing: a user can hold either without the other. ::: In the default role configuration, the **System Admin**, **Project Admin**, and **Member Admin** roles hold `ManageTags`; the other default roles do not. Instances can customize roles, so check with your administrator when in doubt. Querying and searching require only normal authenticated access. ## Related Topics - [rest-1.v1](/apis/rest-1) — writing `TaggedWith` on an individual asset - [Bulk API (api/asset)](/apis/asset-bulk) — tagging many assets in one request - [Attributes](/concepts/attributes) - [API conventions](/apis/conventions) --- # Query a Tag - URL: https://docs.digital.ai/agility-api/apis/tags/query - Markdown: https://docs.digital.ai/agility-api/apis/tags/query.md - Source: apis/tags/query.md - Description: Retrieve usage counts for a single tag with GET ~/api/tags?tag=. - Tags: api, tags, query Returns usage information for one specified tag. ## Request ```text GET ~/api/tags?tag= ``` | Parameter | In | Required | Description | | --------- | ----- | -------- | ----------------------------------------------------------------------------------------------- | | `tag` | query | yes | The tag name to look up. URL-encode spaces and symbols. | | `field` | query | no | A custom tag attribute to look in instead of the standard `TaggedWith`. See [custom tag fields](#custom-tag-fields). | ```bash title="Request" curl "https://{host}/{instance}/api/tags?tag=UX" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ## Response ```json title="200 OK" { "tag": "UX", "colorName": "yellow500", "count": 1, "visibleCount": 1 } ``` | Field | Type | Meaning | | -------------- | ------- | ------------------------------------------------------------------------- | | `tag` | string | The tag as stored, with its original casing. | | `colorName` | string | The display colour assigned to the tag, e.g. `yellow500`. May be `null`. | | `count` | integer | Total usages of the tag across the whole instance. | | `visibleCount` | integer | Usages on assets **the calling member can see**. | `count` and `visibleCount` diverging is the normal case for a project-scoped member, not an error. See [scope visibility](/apis/tags#scope-visibility). ## Tag Not Found An unknown tag returns **`404 Not Found`**, not a zero-count object. The body is the same shape with an added `message`: ```json title="404 Not Found" { "tag": null, "count": 0, "visibleCount": 0, "message": "Tag not found" } ``` The `message` is localized, so do not match on its text — check the status code. ## Custom Tag Fields By default this endpoint reads the standard `TaggedWith` attribute shared by all tagged assets. Instances that define **custom tag-typed attributes** can target one with the `field` parameter, passing the attribute definition token: ```bash title="Query a custom tag field on Story" curl "https://{host}/{instance}/api/tags?tag=cTag1&field=Story.Custom_cTagStory" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` Omitting `field` — the normal case — reads the standard tag attribute across all asset types. :::note Undocumented in the original reference The `field` parameter and the `colorName` response field were both absent from the published reference material. `field` is also accepted by [merge](/apis/tags/merge) and [remove](/apis/tags/remove). ::: ## Notes - Tag matching is not case-sensitive, but the `tag` value in the response echoes the stored casing. Query `ux` and you may get `"tag": "UX"` back. - To find tags when you only know part of the name, use [search](/apis/tags/search) instead. ## Related Topics - [Query all tags](/apis/tags/query-all) - [Search tags](/apis/tags/search) - [Tags overview](/apis/tags) --- # Query All Tags - URL: https://docs.digital.ai/agility-api/apis/tags/query-all - Markdown: https://docs.digital.ai/agility-api/apis/tags/query-all.md - Source: apis/tags/query-all.md - Description: Retrieve every tag in use across the instance with usage counts, via GET ~/api/tags/all. - Tags: api, tags, query Returns every tag in use across the instance, with the same usage breakdown as [querying a single tag](/apis/tags/query). ## Request ```text GET ~/api/tags/all ``` ```bash title="Request" curl "https://{host}/{instance}/api/tags/all" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ## Response The response is a **JSON object, not an array**. Each key is a tag name; each value carries the tag's stored form plus its counts. ```json title="200 OK" { "acceptanceTestDetails": { "tag": "AcceptanceTestDetails", "colorName": "yellow500", "count": 1, "visibleCount": 1 }, "acceptanceTestDetails 2": { "tag": "AcceptanceTestDetails 2", "colorName": "blue500", "count": 1, "visibleCount": 1 }, "acceptanceTestDetails 3": { "tag": "AcceptanceTestDetails 3", "colorName": null, "count": 1, "visibleCount": 1 } } ``` | Field | Type | Meaning | | -------------- | ------- | ------------------------------------------------------------- | | `tag` | string | The tag as stored, with its original casing. | | `colorName` | string | The display colour assigned to the tag, e.g. `yellow500`. May be `null`. | | `count` | integer | Total usages across the whole instance. | | `visibleCount` | integer | Usages on assets the calling member can see. | :::warning Read the value, not the key The object keys are **not** the tag strings. They are passed through the API's camel-casing JSON serializer, which lowercases the first character — the tag `AcceptanceTestDetails` appears under the key `acceptanceTestDetails`. **The `tag` field inside each value is the authoritative tag string.** That is what to display and what to pass to [merge](/apis/tags/merge) or [remove](/apis/tags/remove). Keying off the object key will usually still work, because tag matching is case-insensitive, but you will render the wrong casing to your users. ::: ## Notes - There is **no paging** on this endpoint. On an instance with a large, uncurated tag vocabulary the response can be substantial. Enable gzip (`Accept-Encoding: gzip`) and cache the result rather than calling it per page view. - `visibleCount` is relative to the calling member. Two members hitting this endpoint will get different numbers against the same `count`. - A tag with `visibleCount` of `0` and a non-zero `count` means the tag exists but only on assets outside the caller's scope visibility. ## Finding Unused or Near-Duplicate Tags The typical vocabulary-cleanup workflow starts here: pull the full list, sort by `count` ascending to find one-off tags, and look for casing and spacing variants of the same intent (`RegressionTests`, `Regression Tests`, `RegTests`). Then collapse them with [merge](/apis/tags/merge). ## Related Topics - [Query a tag](/apis/tags/query) - [Merge tags](/apis/tags/merge) - [Tags overview](/apis/tags) --- # Search Tags - URL: https://docs.digital.ai/agility-api/apis/tags/search - Markdown: https://docs.digital.ai/agility-api/apis/tags/search.md - Source: apis/tags/search.md - Description: Substring-match tag names with GET ~/api/tags/command/search?term=. - Tags: api, tags, search Substring search over tag names. Use this to power a type-ahead, or to find the variants of a tag you are about to [merge](/apis/tags/merge). ## Request ```text GET ~/api/tags/command/search?term= ``` | Parameter | In | Required | Description | | --------- | ----- | -------- | ---------------------------------------------- | | `term` | query | yes | Substring to match against tag names. | ```bash title="Request" curl "https://{host}/{instance}/api/tags/command/search?term=Test" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ## Response A flat JSON array of matching tag strings — **no counts**. If you need usage information for a match, follow up with [query a tag](/apis/tags/query). ```json title="200 OK" [ "Automated SQL UnitTests", "ProductPlanning Regression Tests", "RegressionTestDetails", "RegressionTests", "Testboard", "TestSetDetails" ] ``` Note that the match is a **substring match anywhere in the name**, not a prefix match: `Testboard` and `RegressionTestDetails` both matched `Test`. ## Notes - Matching is case-insensitive. - An empty result set is returned as `[]`, not a `404`. - Despite living under the `/command/` path segment alongside `merge` and `remove`, search is a read-only `GET` and requires no special privilege. ## Related Topics - [Query all tags](/apis/tags/query-all) — when you want the whole vocabulary with counts - [Merge tags](/apis/tags/merge) - [Tags overview](/apis/tags) --- # Merge Tags - URL: https://docs.digital.ai/agility-api/apis/tags/merge - Markdown: https://docs.digital.ai/agility-api/apis/tags/merge.md - Source: apis/tags/merge.md - Description: Collapse several tags into one across every asset with POST ~/api/tags/command/merge. - Tags: api, tags, merge, admin Merges several tags into a single tag. Every tag listed in `MergeFrom` is removed from the assets carrying it, and `MergeTo` is added to those assets in its place. This is the supported way to **rename a tag** — merge the old name into the new one. ## Request ```text POST ~/api/tags/command/merge ``` ### Payload ```json { "mergeTo": "Regression Tests", "mergeFrom": ["RegressionTests", "RegTests"] } ``` | Field | Type | Required | Description | | ----------- | -------- | -------- | ------------------------------------------------------------------------------------------------ | | `mergeTo` | string | yes | The surviving tag. May be an existing tag or an entirely new one. | | `mergeFrom` | string[] | yes | Tags to collapse. Each is removed from every visible asset carrying it. | | `field` | string | no | A [custom tag attribute](/apis/tags/query#custom-tag-fields) to operate on instead of `TaggedWith`. | :::note Casing on the wire The published reference showed these fields PascalCased (`MergeTo`, `MergeFrom`). Both work — the server's JSON binding matches property names case-insensitively — but the API's JSON serializer is configured for camelCase, so **`mergeTo` / `mergeFrom` is the canonical form** and is what responses elsewhere in this API use. Existing integrations sending PascalCase do not need to change. ::: ```bash title="Request" curl -X POST "https://{host}/{instance}/api/tags/command/merge" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "mergeTo": "Regression Tests", "mergeFrom": ["RegressionTests", "RegTests"] }' ``` :::note Merging into a new tag is allowed `mergeTo` does not have to exist beforehand. Merging `RegressionTests` and `RegTests` into `Regression Tests` creates that tag on every affected asset even if nothing was tagged that way before. ::: ## Behavior - An asset carrying **more than one** of the `mergeFrom` tags ends up with a single `mergeTo` tag, not duplicates. - An asset that already had `mergeTo` is not double-tagged. - Tags in `mergeFrom` that do not exist are ignored rather than erroring. - The operation writes to every affected asset, so it appears in asset history and [activity stream](/apis/activity-stream) output attributed to the calling member. ## Permissions and Scope Merge requires the **`ManageTags`** privilege. :::warning A merge is only as wide as your visibility The command acts only on assets the caller can see and edit. If you run it as a project-scoped member, the `MergeFrom` tags survive on assets outside your scope — and the response will not tell you. Run vocabulary cleanup as a member with instance-wide visibility, then re-check with [query all tags](/apis/tags/query-all) to confirm the old names are gone. ::: :::danger There is no undo Merge rewrites the `TaggedWith` attribute on every matching asset. There is no batch rollback. Before merging, capture the current state with [query all tags](/apis/tags/query-all), or query the affected assets and their tags via [`query.v1`](/apis/query-v1), so you can reconstruct if the merge turns out to be wrong. ::: ## Related Topics - [Remove tags](/apis/tags/remove) — purge rather than rename - [Search tags](/apis/tags/search) — find the variants to merge - [Tags overview](/apis/tags) --- # Remove Tags - URL: https://docs.digital.ai/agility-api/apis/tags/remove - Markdown: https://docs.digital.ai/agility-api/apis/tags/remove.md - Source: apis/tags/remove.md - Description: Purge tags from every asset carrying them with POST ~/api/tags/command/remove. - Tags: api, tags, admin Removes the specified tags from every asset they exist on. Because a tag has no existence independent of the assets carrying it, removing it from every asset removes it from the instance. ## Request ```text POST ~/api/tags/command/remove ``` ### Payload ```json { "removeFrom": ["RegressionTests", "RegTests"] } ``` | Field | Type | Required | Description | | ------------ | -------- | -------- | ------------------------------------------------------------------------------------------------ | | `removeFrom` | string[] | yes | The tags to remove. Each is stripped from every visible asset carrying it. | | `field` | string | no | A [custom tag attribute](/apis/tags/query#custom-tag-fields) to operate on instead of `TaggedWith`. | ```bash title="Request" curl -X POST "https://{host}/{instance}/api/tags/command/remove" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "removeFrom": ["RegressionTests", "RegTests"] }' ``` :::warning Older documentation showed the wrong payload Earlier reference material showed the **merge** payload — `MergeTo` plus `MergeFrom` — for this endpoint. That shape is wrong here: the endpoint binds `removeFrom` and `field`, and a request using the merge fields is **rejected with `400 Bad Request`**. If you built an integration from the old example, it stopped at the door — correct the payload to `removeFrom`. ::: ## Behavior - Tags in `removeFrom` that do not exist are ignored rather than erroring. - The operation writes to every affected asset, so it appears in asset history and [activity stream](/apis/activity-stream) output attributed to the calling member. - Other tags on those assets are untouched. ## Permissions and Scope Remove requires the **`ManageTags`** privilege, and acts only on assets the caller can see and edit — the same limitation described for [merge](/apis/tags/merge#permissions-and-scope). :::danger There is no undo Removal rewrites the `TaggedWith` attribute on every matching asset and cannot be batch-reversed. If you may need the association back, capture it first — query the affected assets and their tags through [`query.v1`](/apis/query-v1) and keep the result. If your goal is to rename rather than purge, use [merge](/apis/tags/merge) instead: it preserves the association under a new name. ::: ## Related Topics - [Merge tags](/apis/tags/merge) - [Query all tags](/apis/tags/query-all) — verify the tag is gone afterwards - [Tags overview](/apis/tags) --- # Activity Stream (api/ActivityStream) - URL: https://docs.digital.ai/agility-api/apis/activity-stream - Markdown: https://docs.digital.ai/agility-api/apis/activity-stream.md - Source: apis/activity-stream/index.md - Description: Read-only feed of what happened to an asset — who did what, when — with verb filtering and date-anchored paging. - Tags: api, activity-stream, history, audit The activity stream is a **read-only, human-oriented feed** of what has happened to an asset: who did what, when, and what changed. It is the data behind the activity panel in the Agility UI, exposed directly. ## When to Use It — and When Not To :::warning The activity stream is not an audit log The stream is **not guaranteed to contain every change** to an asset. It is a curated, presentation-oriented feed, and some changes are deliberately not surfaced in it. For an authoritative accounting of changes, use **asset history** — the `Hist` namespace on [`rest-1.v1`](/apis/rest-1), or an `asof` query on [`query.v1`](/apis/query-v1). Know that history has limits of its own: a few attributes are not tracked at all — rank order (`Order`) changes, for example, leave no history — and neither surface offers a way to list which changes it records. Do not build compliance, audit or reconciliation logic on the activity stream. ::: Use the activity stream when you want to render a "recent activity" feed with the same content and phrasing the product itself shows. Use asset history when you need completeness. ## Endpoint ```text GET ~/api/ActivityStream/:streamName ``` :::note Path casing The route is registered as `api/ActivityStream` — capital `A`, capital `S` Some older navigation labels showed a lowercase `activityStream`, which is why both spellings circulate. ASP.NET routing is case-insensitive, so `api/activitystream` reaches the same handler in practice. **`api/ActivityStream` is the canonical form** and is what this documentation uses. If you are behind a proxy or gateway that normalizes or case-matches paths, use the canonical casing. ::: The stream is identified by a **stream name**, which the server resolves in one of two ways: | Form | Example | Notes | | ----------------------- | ------------ | --------------------------------------------------------------------- | | An [Oid token](/apis/conventions/terminology#oid) | `Story:1220` | The normal case. Resolved to the momentless token internally. | | A workitem **number** | `S-01047` | The user-visible number. Convenient, but costs an extra lookup. | If the value resolves to neither, the request returns **`404 Not Found`**. :::note Number lookup is workitem-only The number form searches the `Workitem` asset type. It works for stories, defects, tasks and tests, but not for assets outside that hierarchy. Prefer the Oid token — it is unambiguous and avoids the search. ::: Deleted assets still have a resolvable stream: the lookup runs with `ConsiderDeleted` enabled, so you can read the activity of something that has since been deleted. ## Query Parameters | Parameter | Type | Default | Description | | ------------ | -------- | ------------------ | ------------------------------------------------------------------------------------------- | | `anchorDate` | datetime | now (UTC) | The point in time to read from. | | `direction` | enum | `Back` | `Back` for activity **earlier** than the anchor, `Forward` for **later**. Case-insensitive. | | `maxCount` | integer | `25` | Number of activities to return. | | `verbs` | string | none (no filter) | Comma-separated list of verbs to filter on. **Case-sensitive.** | Any parameter that fails to parse returns **`400 Bad Request`** — the request is rejected rather than silently falling back to the default. ## Counting a Stream ```text GET ~/api/ActivityStream/:streamName/count ``` Returns the **total number of activities** in the stream as a bare integer — not JSON-wrapped, and not affected by `maxCount`, `verbs` or the other query parameters. ```bash title="Request" curl "https://{host}/{instance}/api/ActivityStream/Story:1220/count" \ -H "Authorization: Bearer " ``` ```text title="200 OK" 42 ``` :::note Undocumented in the original reference The `/count` route was absent from the published reference material. ::: ### Paging Through a Stream There is no page number. Paging is done by **moving the anchor**: read a page with `direction=Back`, take the `time` of the last entry, and use it as the next request's `anchorDate`. ```bash title="First page" curl "https://{host}/{instance}/api/ActivityStream/Story:1220?maxCount=25" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ```bash title="Next page — anchored on the last entry's time" curl "https://{host}/{instance}/api/ActivityStream/Story:1220?maxCount=25&anchorDate=2014-04-22T14:11:01.1175003Z&direction=Back" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` Two activities can share a timestamp, so a naive anchor-on-last-time loop can repeat or skip an entry at a page boundary. De-duplicate on the activity's identity when stitching pages together. ## Verbs `verb` describes what the actor did. Filter the feed by passing a comma-separated list: ```text GET ~/api/ActivityStream/Story:1220?verbs=Created,Updated,Closed ``` The complete set defined by the server is: | Verb | Typical meaning | | ------------- | ---------------------------------------------------------------- | | `Added` | A value was added to a multi-value relation. | | `Blocked` | The item was marked blocked. | | `Deleted` | The asset was deleted (asset state `255`). | | `Deprecated` | The asset was deprecated. | | `Closed` | The asset was closed or quick-closed. | | `Created` | The asset was created. | | `Migrated` | The asset was migrated. | | `Moved` | The asset was moved — typically to a different project. | | `Reopened` | A closed asset was reopened. | | `Removed` | A value was removed from a multi-value relation. | | `UnBlocked` | A block was cleared. Note the internal capital `B`. | | `Updated` | One or more attributes were set. | | `Undeleted` | A deleted asset was restored. | | `Activated` | The asset was activated. | | `Deactivated` | The asset was deactivated. | | `Transformed` | The asset was transformed into another type. | | `Pinned` | The item was pinned. | | `Unpinned` | The item was unpinned. | :::note Don't switch exhaustively on this list Treat the table above as the current set, not a closed one, and handle any unrecognized verb gracefully rather than erroring on it. ::: :::danger Verb filtering is case-sensitive `verbs` values are parsed with an exact, case-sensitive enum match. `verbs=updated` does **not** mean `Updated` — it returns **`400 Bad Request`**, as does any unrecognized name. One bad entry rejects the whole request; there is no partial filtering. Note the irregular casing this makes significant: `UnBlocked` has an internal capital `B`, while `Undeleted` and `Unpinned` do not. This is inconsistent with `direction`, which *is* parsed case-insensitively. ::: ## Related Topics - [Query activity](/apis/activity-stream/query) — full request and response walkthrough- [rest-1.v1](/apis/rest-1) — asset history, for a complete change record - [Webhooks](/apis/webhooks) — push notification instead of polling this feed - [Terminology](/apis/conventions/terminology) — Oid tokens --- # Query Activity - URL: https://docs.digital.ai/agility-api/apis/activity-stream/query - Markdown: https://docs.digital.ai/agility-api/apis/activity-stream/query.md - Source: apis/activity-stream/query.md - Description: Retrieve the activity feed for an asset, with a field-by-field walkthrough of the response. - Tags: api, activity-stream, query Retrieve the activities recorded against an asset. ## Request ```text GET ~/api/ActivityStream/:streamName ``` Where `:streamName` is an [Oid token](/apis/conventions/terminology#oid) such as `Story:1220`, or a workitem number such as `S-01047`. See [stream names](/apis/activity-stream#endpoint). ```bash title="Request" curl "https://{host}/{instance}/api/ActivityStream/Story:1220?maxCount=25&direction=Back" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` See [query parameters](/apis/activity-stream#query-parameters) for `anchorDate`, `direction`, `maxCount` and `verbs`. ## Response A JSON **array**. Each element wraps a `body` object describing one activity. ```json title="200 OK" [ { "body": { "actor": { "id": "Member:1065", "assetType": "Member", "displayName": "Tammy Coder", "username": "tammy", "email": "tammy.coder@company.com", "avatar": "Image:1936" }, "verb": "Updated", "object": { "id": "Story:1220", "assetType": "Story", "displayName": "Delete RMA", "number": "S-01047", "assetState": "Active", "scope": "Scope:1258" }, "time": "2014-04-22T14:11:01.1175003Z", "summary": "Tammy Coder Updated Story 'Delete RMA'", "provider": { "stream": "MetaStream", "commitSeq": "237", "commitId": "01a7b036-01f9-47a1-86b0-17a9c4c25544" }, "target": [ { "name": "Timebox", "newValue": { "id": "Timebox:1785", "assetType": "Timebox", "displayName": "Sprint 6", "assetState": "Future" }, "oldValue": "", "verb": "Set", "summary": "Timebox was Set to 'Sprint 6'" }, { "name": "Estimate", "newValue": "3.00", "oldValue": "2.00", "verb": "Set", "summary": "Estimate was Set to '3.00'" }, { "name": "Priority", "newValue": "Medium", "oldValue": "High", "verb": "Set", "summary": "Priority was Set to 'Medium'" }, { "name": "Risk", "newValue": "Low", "oldValue": "High", "verb": "Set", "summary": "Risk was Set to 'Low'" } ] } } ] ``` ### `body` | Field | Type | Description | | ---------- | ------ | ---------------------------------------------------------------------------------- | | `actor` | object | The member who performed the action. | | `verb` | string | What was done. See the [verb list](/apis/activity-stream#verbs). | | `object` | object | The asset the action was performed on. | | `time` | string | UTC timestamp, ISO 8601. Use this as the next page's `anchorDate`. | | `summary` | string | Pre-rendered, **localized** human-readable sentence. | | `provider` | object | Where the activity came from in the event store. | | `target` | array | The individual changes that made up this activity. May be empty. | ### `actor` | Field | Description | | ------------- | ------------------------------------------------------------------ | | `id` | Oid token of the member, e.g. `Member:1065`. | | `assetType` | Always `Member`. | | `displayName` | The member's display name. | | `username` | The member's login name. | | `email` | The member's email address. | | `avatar` | Oid token of the avatar image, e.g. `Image:1936` — **not a URL**. | ### `object` | Field | Description | | ------------- | ------------------------------------------------------------------------ | | `id` | Oid token of the affected asset. | | `assetType` | Asset type system name, e.g. `Story`. | | `displayName` | The asset's name at the time the feed was rendered. | | `number` | The user-visible number, e.g. `S-01047`. Absent on types without one. | | `assetState` | Asset state **as a name** (`Active`), not the numeric code. | | `scope` | Oid token of the owning project. | :::note `assetState` is a name here, a number elsewhere The activity stream reports `"assetState": "Active"`. The same concept on the asset itself is the numeric `AssetState` attribute (`64`). See [asset states](/apis/conventions/terminology#asset-state) for the mapping. ::: ### `provider` | Field | Description | | ----------- | ------------------------------------------------------------------------------ | | `stream` | The originating event stream, e.g. `MetaStream`. | | `commitSeq` | Monotonic sequence number of the commit that produced this activity. | | `commitId` | GUID of the commit. Activities sharing a `commitId` came from one save. | `commitId` is the reliable way to group changes made together in a single save — more reliable than grouping on `time`, since several activities can share a timestamp. ### `target[]` One entry per attribute changed. | Field | Type | Description | | ---------- | ---------------- | --------------------------------------------------------------------------- | | `name` | string | The attribute definition name, e.g. `Estimate`. | | `oldValue` | string \| object | Previous value. Empty string when there was none. | | `newValue` | string \| object | New value. | | `verb` | string | The change verb for this specific attribute, e.g. `Set`. | | `summary` | string | Pre-rendered, localized sentence for this change. | :::warning `oldValue` and `newValue` are polymorphic For a **scalar** attribute they are strings — note that numbers arrive as strings too (`"3.00"`, not `3.00`). For a **relation** they are objects carrying `id`, `assetType`, `displayName` and `assetState`, as with `Timebox` in the example above. Type your client to accept either. A parser that assumes string will break the first time someone reassigns a sprint. ::: Also note that `target[].verb` (`Set`) is a different vocabulary from `body.verb` (`Updated`). The body verb describes the activity; the target verb describes what happened to that one attribute. ## Notes - `summary` fields are **localized** for the caller. Do not pattern-match on them; parse `target[]` instead. - The response is not wrapped in a paging envelope — there is no `total`. Use [anchor-date paging](/apis/activity-stream#paging-through-a-stream), or [`/count`](/apis/activity-stream#counting-a-stream) if you need the total. - An asset with no recorded activity returns `[]`. An unresolvable stream name returns `404`. ## Related Topics - [Activity stream overview](/apis/activity-stream) — parameters, verbs and caveats - [rest-1.v1](/apis/rest-1) — asset history, for a complete change record - [Webhooks](/apis/webhooks) — get pushed the change instead of polling --- # Asset Permalinks (assetdetail.v1) - URL: https://docs.digital.ai/agility-api/apis/asset-permalinks - Markdown: https://docs.digital.ai/agility-api/apis/asset-permalinks.md - Source: apis/asset-permalinks.md - Description: Turn an OID or an asset Number into a clickable link that opens the asset in the Agility UI. - Tags: api, permalink, ui, integration The bridge from API identifiers to the product UI: give it an OID or an asset Number, and it redirects to the asset's detail view. This is the URL to emit whenever an integration mentions an asset to a **human** — a chat message, an email, a webhook consumer's UI. ```text GET ~/assetdetail.v1?oid=Story:1154 GET ~/assetdetail.v1?Number=S-01001 ``` Both forms answer `302 Found` to the asset's per-type view — for example `story.mvc/Summary?oidToken=Story:1154`. The `Number` form resolves the human-facing number to the asset first, so it is the right choice when all you have is what a person typed. Parameter names are case-insensitive. | Input | Result | | --- | --- | | `oid=` | `302` to the asset's detail view | | `Number=` | Resolved to the asset, then `302` to its detail view | | A Number that matches nothing | `404` | | Unauthenticated request | `302` to the login page, **with the permalink preserved as the destination** — the reader lands on the asset after signing in | Because the login redirect carries the destination, these links are safe to share with people who are not signed in yet — which is exactly what makes them permalinks rather than session URLs. :::note Emit permalinks for people, OIDs for programs An `assetdetail.v1` URL is for eyeballs. Programs consuming your output should get the OID token (or a [`rest-1.v1`](/apis/rest-1) address); a program that follows the permalink gets the HTML UI, not data. ::: Webhook payloads already use this endpoint: the `href` fields on delivered events are `assetdetail.v1?oid=…` permalinks — see [webhook payloads](/apis/webhooks/payload). ## Related - [Webhook payloads](/apis/webhooks/payload) — where these links appear in delivered events - [Working with comments](/guides/working-with-comments) — pair a mention with a permalink so the notified member can click through --- # Localization (loc-2.v1) - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1 - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1.md - Source: apis/loc-2-v1/index.md - Description: The localization API and the localizer engine behind it: term stacks, nested substitution, culture fallback, template set overrides, hot reload and noloc. - Tags: api, localization, terminology, i18n - Source refs: versionone-localization, core-docsource-loc, api-docs-loc, core-source `~/loc-2.v1` turns Agility's internal, English, machine-facing names into the words a user actually sees. Almost every other Agility API hands you raw identifiers — `Scope`, `Timebox`, `AttributeDefinition'Name'Story` — and leaves the display text to you. `loc-2.v1` is where that display text comes from. ```bash title="One request, many tags" curl "https://www.example.com/VersionOne/loc-2.v1?[Scope,Timebox,AttributeDefinition'Name'Story]" \ -H "Accept-Language: en-US" ``` ```json { "Scope": "Project", "Timebox": "Sprint", "AttributeDefinition'Name'Story": "Title" } ``` :::note The `-2` matters The endpoint is `loc-2.v1`, not `loc.v1`. `loc-2.v1` is the bulk, JSON endpoint documented here. `loc.v1` is the older single-tag, `text/plain` endpoint; it still exists and is described in [Requests and responses](/apis/loc-2-v1/requests-and-responses#legacy-locv1). ::: ## Why This Section Is Unusually Deep The endpoint itself is trivial: send tags, get strings. What is not trivial is _predicting_ the string you get back. The values are produced by a pattern-based localization engine whose behaviour is genuinely surprising if you have only seen the endpoint: - A tag is not a key. It is a **stack of terms** that resolves from most to least specific, so `Plural'Story` can be answered by a template registered under `Plural`. See [Tag resolution](/apis/loc-2-v1/tag-resolution). - Template bodies contain **nested tags** in `{}` that are resolved recursively, and can splice in the terms of the tag being resolved by numeric index. See [Nested tag substitution](/apis/loc-2-v1/nested-tags). - A culture that has no entry falls back along a chain, and — the part nobody expects — **nested tags inside a fallback template still resolve against the requested culture**. See [Culture fallback](/apis/loc-2-v1/culture-fallback). - Several **template sets** are stacked on top of each other, later sets overriding earlier ones, and an empty value _deletes_ an entry rather than blanking it. See [Template sets and override order](/apis/loc-2-v1/template-sets). - Templates are plain text files that are **reloaded while the application is running**. See [Hot reload and caching](/apis/loc-2-v1/hot-reload). If you are debugging a screen or an integration and want to see the raw tags instead of localized text, read [Debugging with `noloc`](/apis/loc-2-v1/noloc) first. It is the single most useful trick in this section and almost nobody knows about it. ## When to Use It | You want to… | Use | | ------------------------------------------------------------- | -------------------------------------------------------------------------- | | Render an asset type name in a UI | `loc-2.v1?[Scope]` | | Render a field label | `loc-2.v1?[AttributeDefinition'Name'Story]` | | Render many labels for one screen | One `loc-2.v1?[…]` request with every tag — it is designed for this | | Discover which attributes exist in the first place | [meta.v1](/apis/meta-v1), then localize the names you get back | | Find out what an internal name _means_ | [Glossary](/reference/glossary) | `loc-2.v1` never tells you which tags exist. It is a pure resolver: any string you send is a valid tag, and an unmatched tag comes back unchanged. Discovery is [meta.v1](/apis/meta-v1)'s job. ## How Trustworthy Is This Section? Some older descriptions of this API are still in circulation and contradict this section in places. See [Corrections to older documentation](/apis/loc-2-v1/requests-and-responses#corrections-to-older-documentation). ## In This Section | Page | What it answers | | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | | [Requests and responses](/apis/loc-2-v1/requests-and-responses) | Methods, query syntax, response shapes, auth, errors, caching | | [Tag resolution](/apis/loc-2-v1/tag-resolution) | How a tag's term stack is walked, and what wins | | [Nested tag substitution](/apis/loc-2-v1/nested-tags) | `{}` placeholders, numeric indices, literal braces, recursion | | [Culture fallback](/apis/loc-2-v1/culture-fallback) | `Accept-Language`, the fallback chain, partial matches | | [Template sets and override order](/apis/loc-2-v1/template-sets) | `Base`, edition sets, `Company`, terminology overrides, deletion | | [Template file format](/apis/loc-2-v1/template-file-format) | The `.txt` syntax, encoding, comments, continuations, escaping | | [Hot reload and caching](/apis/loc-2-v1/hot-reload) | Editing templates on a running instance, and the ETag caveat | | [Debugging with `noloc`](/apis/loc-2-v1/noloc) | Seeing raw tags instead of localized output | ## Related Topics - [meta.v1](/apis/meta-v1) — discover the asset types and attribute definitions whose names you localize - [API conventions](/apis/conventions) — base URL and header conventions shared across endpoints - [Glossary](/reference/glossary) — what Agility's internal names mean - [HTTP status codes](/reference/status-codes) --- # Requests and Responses - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/requests-and-responses - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/requests-and-responses.md - Source: apis/loc-2-v1/requests-and-responses.md - Description: Methods, query-string syntax, response shapes, authentication, errors and caching for the loc-2.v1 localization endpoint. - Tags: api, localization, reference ## Endpoint ```text GET ~/loc-2.v1? HEAD ~/loc-2.v1? POST ~/loc-2.v1 ``` `~` is your Agility instance root, for example `https://www.example.com/VersionOne`. Responses are `application/json`. `~/loc-2.img` is registered to the same handler and behaves identically; it exists only so that callers can vary the URL for cache-busting purposes. ## The Query String Is Not a Normal Query String The entire raw query string — everything after the `?`, with no `name=value` structure — is URL-decoded and then parsed as **LSON**, Agility's lenient JSON dialect. What you send determines the shape of what you get. ### Array in, Object Out This is the form you almost always want. ```bash title="Bulk request" curl "https://www.example.com/VersionOne/loc-2.v1?[Scope,Timebox,Story]" \ -H "Accept-Language: en-US" ``` ```json { "Scope": "Project", "Timebox": "Sprint", "Story": "Story" } ``` Keys are the tags exactly as you sent them, in request order. Duplicate tags collapse into a single entry. ### Scalar in, String Out ```bash title="Single-tag request" curl "https://www.example.com/VersionOne/loc-2.v1?Scope" ``` ```json "Project" ``` Note the response is a bare JSON string, not an object. Prefer the array form even for one tag so your parsing stays uniform. ### Edge Cases | Query string | Response | Notes | | ------------------------ | --------- | -------------------------------------------------------- | | `?[Scope,Scope]` | one entry | Duplicates collapse | | `?[]` | `{}` | Empty array | | `?[Scope,,Timebox]` | `"": ""` | An empty element resolves to an empty string, unresolved | | `?` (nothing) | `""` | Empty query string parses as the empty string | | `?null` | `null` | The literal token `null` parses as JSON null | ### Quoting Rules You Will Eventually Hit LSON accepts `'single'` or `"double"` quotes around a string, and treats an unquoted run of characters as a bare token that ends at whitespace, `,` or `]`. Localization tags separate their terms with a tic (`'`), which interacts with that: - A tic **inside** a bare token is fine. `[AttributeDefinition'Name'Story]` parses as one tag, because quoting is only considered at the _start_ of a value. - A tag that **starts** with a tic would be read as an opening quote. Quote it. - A tag containing a space, a comma or a `]` must be quoted, or it will be split. When in doubt, wrap every tag in double quotes and URL-encode the result: ```bash title="Always-safe form" curl -G "https://www.example.com/VersionOne/loc-2.v1" \ --data-urlencode '=["AttributeDefinition'"'"'Name'"'"'Story","Plural'"'"'Story"]' ``` ## POST for Long Tag Lists A GET query string is subject to your web server's URL length limit. For large batches, POST the same LSON payload as the request body. The body is URL-decoded and then parsed identically. ```bash title="POST form" curl -X POST "https://www.example.com/VersionOne/loc-2.v1" \ --data-urlencode '["Scope","Timebox","Story","Plural'"'"'Story"]' ``` POST responses are marked `Cache-Control: no-cache`. ## Authentication **`loc-2.v1` does not require authentication.** The handler overrides the authentication step to always succeed, so no credentials are needed and none are validated. Sending an `Authorization` header anyway is harmless — it is simply ignored. Localization output is not user-specific; it depends only on the requested culture and the instance's configured terminology. ## Choosing a Culture Set `Accept-Language`. Agility reads **only the first entry** of the header and converts it to a specific culture: ```text Accept-Language: fr-CA -> fr-CA Accept-Language: fr -> fr-FR (neutral cultures are expanded to their default specific culture) Accept-Language: xx-YY -> invariant culture (unparseable input is swallowed) (header absent) -> invariant culture ``` Because a neutral culture is expanded before it reaches the localizer, you cannot address a neutral culture's templates directly over HTTP — you always land on a specific culture, which then falls back. What that fallback does is covered in [Culture fallback](/apis/loc-2-v1/culture-fallback). ## Caching `GET` and `HEAD` responses are cacheable: | Header | Value | | --------------- | ------------------------------------------------------ | | `Cache-Control` | `public`, `max-age=3600` | | `Vary` | includes `Accept-Language` | | `ETag` | derived from the newest write time in the strings folder | Send `If-None-Match` with a previously returned ETag and you get `304 Not Modified` with an empty body. There is an important caveat about ETag staleness after a template change — see [Hot reload and caching](/apis/loc-2-v1/hot-reload#the-etag-caveat). ## Errors | Status | Cause | | ------ | ---------------------------------------------------------------------------------------------- | | `405` | Any method other than `GET`, `HEAD`, `POST` or `OPTIONS`. Response carries `Allow: GET,HEAD,POST,OPTIONS`. | | `500` | The payload parsed, but an element was not a string — for example `?[1,2]` or `?[{a:b}]`. The message is `Unsupported Type: `. | | `500` | Any unexpected server-side failure. | `OPTIONS` returns `200` with the `Allow` header and no body. Error bodies are `text/plain`, not JSON — the content type is reset before the error is written. Do not assume a JSON body on a non-2xx response from this endpoint. There is no `400` for an unrecognised tag, because there is no such thing: an unmatched tag resolves to itself. See [Tag resolution](/apis/loc-2-v1/tag-resolution#unmatched-tags-resolve-to-themselves). ## Legacy `loc.v1` `~/loc.v1` predates `loc-2.v1` and is still registered. | | `loc.v1` | `loc-2.v1` | | --- | --- | --- | | Methods | `GET`, `HEAD` | `GET`, `HEAD`, `POST` | | Payload | one raw tag as the whole query string | LSON scalar or array | | Content type | `text/plain` | `application/json` | | Empty query string | `400 Missing TAG Parameter` | `""` | ```bash title="Legacy single-tag request" curl "https://www.example.com/VersionOne/loc.v1?AttributeDefinition'Name'Story" ``` ```text Title ``` Both endpoints resolve tags through exactly the same localizer, so every semantic page in this section applies equally to both. Use `loc-2.v1` for new work: one round trip for a whole screen's worth of labels beats one round trip per label. ## Corrections to Older Documentation Older descriptions of this API are still in circulation. Where what you have read disagrees with this section, these are the corrections: | You may have read | The reality | | --- | --- | | The endpoint is `loc.v1` | That is the legacy endpoint. `loc-2.v1` exists and is the bulk form. | | Responses are `text/plain` | True of `loc.v1`. `loc-2.v1` returns `application/json`. | | Requests need an `Authorization` header | No authentication is required or checked. | | The override set is called `Custom` | `Custom` is a sample application's set name. Agility's in-field override file is `Company`. See [Template sets](/apis/loc-2-v1/template-sets#the-set-stack-agility-actually-uses). | ## Related Topics - [Tag resolution](/apis/loc-2-v1/tag-resolution) - [Culture fallback](/apis/loc-2-v1/culture-fallback) - [HTTP status codes](/reference/status-codes) - [Error handling](/guides/error-handling) --- # Tag Resolution: the Term Stack - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/tag-resolution - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/tag-resolution.md - Source: apis/loc-2-v1/tag-resolution.md - Description: How the Agility localizer resolves a tag through a stack of terms, from most specific to least specific, and what wins. - Tags: api, localization, concepts ## A Tag Is a Stack, Not a Key A localization tag is a **tic-separated stack of terms**, ordered from most general to most specific: ```text AttributeDefinition'Name'Story └─ term 0 ──┘ └─ 1 ┘ └─ 2 ┘ ``` Terms may not themselves contain a tic (`'`), because the tic is the separator. When resolving a tag, the localizer looks for a template registered under the whole tag, then progressively drops terms from the right until it finds one: ```text Resolving Plural'AssetType'Story try Plural'AssetType'Story try Plural'AssetType try Plural ``` The first hit wins. This is why a single generic template can answer an unbounded family of tags, and why adding one specific template quietly overrides just that case. The resolver trims the tag at its last separator, looks again, and repeats until a template answers or nothing is left to trim. ### Worked Example Given these templates: ```text Plural={1}s Plural'Story=Stories ``` | Tag | Template found | Result | | --------------- | -------------- | --------- | | `Plural'Scope` | `Plural` | `Scopes` | | `Plural'Story` | `Plural'Story` | `Stories` | The specific entry shadows the generic one for exactly one tag, and nothing else changes. A three-term case behaves the same way: ```text Plural={1}s ``` resolves `Plural'AssetType'Story` by falling all the way back to `Plural`, whose body then substitutes term 1 — `AssetType` — giving `AssetTypes`. ## Specificity Beats Position Adding a longer tag never disturbs shorter ones, and the search always prefers the longest registered prefix: ```text Plural={1}s Plural'Story=Stories Report2=Report {Plural'1}/{Plural'2} Report2'Member=Report: User by {2} Scope=Project ``` | Tag | Result | | ----------------------- | ------------------------- | | `Report2'Scope'Story` | `Report Projects/Stories` | | `Report2'Member'Story` | `Report: User by Story` | | `Report2'Member'Scope` | `Report: User by Project` | ## Unmatched Tags Resolve to Themselves If no prefix of the tag matches anywhere in the chain, the localizer returns the tag itself, trimmed. It never errors, never returns null, and never omits the key from a `loc-2.v1` response. ```json title="loc-2.v1?[bogus]" { "bogus": "bogus" } ``` This has two practical consequences: - **You cannot detect a typo from the response.** A misspelled tag looks exactly like a tag that has no translation in the requested culture. If a value comes back identical to the tag you sent, check your spelling before filing a bug. - **English-looking output is the designed default.** Agility's tags are already English words, so an unlocalized instance degrades to readable text rather than to `???`. The empty tag is a special case that is pre-seeded to map to the empty string, so `""` resolves to `""` rather than to itself. ## Results Are Trimmed, and Cached The resolved value is `Trim()`ed before it is returned, which is what makes optional trailing terms produce clean output: ```text A=x {1} ``` | Tag | Result | | ----- | ------ | | `A` | `x` | | `A'B` | `x y` | With no term 1, `{1}` expands to nothing and the trailing space is trimmed away. :::warning Trimming is edges only `Trim()` removes leading and trailing whitespace from the finished string. A missing substitution in the _middle_ of a template leaves a double space behind: `x {1} z` with no term 1 yields `x z`. Put optional terms at the end. ::: Each localizer memoizes resolved tags. The cache is cleared whenever a template is added or removed, so it is invisible to callers — but it does mean the first request after a template change is fractionally slower. ## The Search Order Across the Fallback Chain The trim-the-rightmost-term walk runs to completion **within one culture's template map** before the fallback culture is consulted at all. The fallback is then handed the _original, full_ tag and repeats the whole walk itself. ```text Resolving Plural'AssetType'Story on fr-FR: fr-FR: Plural'AssetType'Story -> Plural'AssetType -> Plural fr: Plural'AssetType'Story -> Plural'AssetType -> Plural en: Plural'AssetType'Story -> Plural'AssetType -> Plural ``` The two dimensions do **not** interleave. A generic `Plural` in `fr-FR` beats a specific `Plural'AssetType'Story` in `en`. Specificity within a culture always outranks specificity in a fallback culture. This trips people up when adding translations: putting a very specific entry in the default English set will not help if the target culture already has a broad generic entry. ## Related Topics - [Nested tag substitution](/apis/loc-2-v1/nested-tags) — what happens once a template is found - [Culture fallback](/apis/loc-2-v1/culture-fallback) — how the fallback chain is built - [Template sets and override order](/apis/loc-2-v1/template-sets) — how templates get into a culture's map --- # Nested Tag Substitution - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/nested-tags - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/nested-tags.md - Source: apis/loc-2-v1/nested-tags.md - Description: How {} placeholders inside a localization template are resolved recursively, how numeric term indices work, and how literal braces are escaped. - Tags: api, localization, concepts Finding a template is only half of resolution. The template **body** is then expanded: anything between `{` and `}` is itself a localization tag, resolved against the same localizer, and its result is spliced in. This is what makes a handful of templates cover thousands of tags. ## Numeric Terms Are Indices into the Tag Being Resolved Inside `{}`, a term that is a whole number is replaced by the term at that zero-based index **of the tag currently being resolved**. Everything else is taken literally. ```text Plural={1}s ``` Resolving `Plural'Story`: 1. Terms are `["Plural", "Story"]`. 2. `{1}` → term 1 → `Story`. 3. The nested tag `Story` is resolved. With no template for `Story`, it resolves to itself. 4. Result: `Storys`. `{0}` is the whole first term; an index past the end of the tag expands to the empty string, which is the mechanism behind optional trailing terms described in [Tag resolution](/apis/loc-2-v1/tag-resolution#results-are-trimmed-and-cached). ## The Substituted Value Is Resolved, Not Re-Resolved The value spliced in is the _resolution_ of the nested tag. It is not fed back through the resolver a second time. This matters when a translated value happens to collide with another tag: ```text Add=Add {1} Story=Backlog Item Backlog Item=bogus ``` `Add'Story` yields `Add Backlog Item`, not `Add bogus`. ## Literal Tags Nest Too A nested tag does not have to reference an index. It can name another tag outright, and that tag's template is expanded in turn — recursively, until no braces remain. ```text Add'Story=Add {Story} Story=Backlog Item ``` `Add'Story` → `Add Backlog Item`. Indices and literals combine freely: ```text Plural={1}s Report2=Report {Plural'1}/{Plural'2} Plural'Story=Stories Scope=Project ``` `Report2'Scope'Story` → `Report Projects/Stories`. ## Braces Nest, Innermost First Expansion runs repeatedly until no `{…}` pairs remain, and the innermost pair is always matched first. That lets you build a tag out of another tag's result: ```text Plural={1}s Plural'AssetType={Plural'{1'2}'x} AssetType'Story=Backlog Item ``` Resolving `Plural'AssetType'Story`, whose terms are `["Plural", "AssetType", "Story"]`: 1. Innermost `{1'2}` → `AssetType'Story` → resolves to `Backlog Item`. 2. The body is now `{Plural'Backlog Item'x}`. 3. That tag's stack walk finds `Plural`, whose `{1}` picks term 1 of `Plural'Backlog Item'x` — `Backlog Item`. 4. Result: `Backlog Items`. The literal `'x` term is padding: it keeps `Backlog Item` at index 1 so the generic `Plural` template still sees it in the right slot. ## Literal Braces Are Doubled To emit a `{` or `}` in the output, double it. Doubled braces are pulled out before expansion starts and restored afterwards, so they never participate in tag matching. ```text open=open {{ brace close=close }} brace match=open {{and}} close nest=open {{{and}}} close and=xyz ``` | Tag | Result | Why | | ------- | ------------------ | -------------------------------------------------------- | | `open` | `open { brace` | `{{` is a literal `{` | | `close` | `close } brace` | `}}` is a literal `}` | | `match` | `open {and} close` | Both braces are literal, so `and` is plain text | | `nest` | `open {xyz} close` | The outer braces are literal; the inner pair is a real tag | `{}` — an empty pair — resolves the empty tag, which is pre-seeded to the empty string, so it emits nothing. ## Recursion Is Guarded, Not Forbidden The localizer keeps a stack of the tags it is currently resolving. If expansion reaches a tag already on that stack, the tag is emitted **literally** instead of being resolved again. There is no exception and no stack overflow. ```text bogus=recursive {0} recursive ``` `bogus` → `recursive bogus recursive`. `{0}` is `bogus`, which is already being resolved, so it is written out as text. :::warning Cycles fail silently A cycle between two templates does not raise an error — it produces output containing a raw tag. If a `loc-2.v1` response contains an unexpected internal name embedded mid-sentence, suspect a cycle in the templates rather than a transport problem. ::: ## Reading a Real Response Putting it together, this is why the following is a perfectly ordinary result on an instance where the `Story` asset type has been renamed: ```bash curl "https://www.example.com/VersionOne/loc-2.v1?[Plural'Story,Command'Delete'Story]" ``` ```json { "Plural'Story": "Backlog Items", "Command'Delete'Story": "Remove Backlog Items" } ``` `Command'Delete` is defined as `Remove {Plural'2}`. Term 2 of `Command'Delete'Story` is `Story`, so the nested tag is `Plural'Story`, which resolves through its own stack walk. One request, three template lookups, and none of the strings involved is stored anywhere in that final form. ## Related Topics - [Tag resolution](/apis/loc-2-v1/tag-resolution) — how the template is found in the first place - [Culture fallback](/apis/loc-2-v1/culture-fallback) — which localizer nested tags resolve against - [Template file format](/apis/loc-2-v1/template-file-format) — writing templates that contain braces --- # Culture Fallback - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/culture-fallback - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/culture-fallback.md - Source: apis/loc-2-v1/culture-fallback.md - Description: How Accept-Language maps to a localizer, how the specific/neutral/default fallback chain works, and what happens when a culture is partially or wholly unmatched. - Tags: api, localization, i18n, concepts ## From Header to Localizer ```text Accept-Language header └─ first entry only └─ CultureInfo.CreateSpecificCulture(...) └─ localizer for that culture └─ fallback chain ``` Two things about the first step surprise people: - **Only the first language in the header is used.** Agility does not evaluate `q` values or try the second-choice language. `Accept-Language: de,fr;q=0.9` is treated as `de`. - **Neutral cultures are expanded.** `CreateSpecificCulture("fr")` yields `fr-FR`. You cannot request a neutral culture over HTTP; you always land on a specific one, which then falls back through the neutral one. An unparseable value, or a missing header, produces the invariant culture, which resolves to the default localizer — English. ## The Chain Each localizer has at most one fallback localizer. `LocalizationManager` wires them up as: ```text specific culture -> neutral (parent) culture -> default culture fr-FR fr en ``` Agility's default culture is `en`. ## Per-Tag Fallback Fallback is evaluated **per tag**, not per culture. A culture that defines only a handful of terms inherits everything else. Using the library's own fixtures, where `en.Base` defines all three tags and `fr.Base` / `fr-FR.Base` define progressively more specific ones: | Requested culture | `TestDefault` | `TestNeutral` | `TestSpecific` | | ----------------- | ----------------- | ----------------- | -------------------------- | | `en` | `Default English` | `Neutral English` | `Specific English` | | `en-US` | `Default English` | `Neutral English` | `Specific American English` | | `fr` | `Default English` | `Neutral French` | `Specific French` | | `fr-FR` | `Default English` | `Neutral French` | `Specific France French` | | `fr-CH` | `Default English` | `Neutral French` | `Specific French` | | `de` | `Default English` | `Neutral English` | `Specific English` | | `de-AT` | `Default English` | `Neutral English` | `Specific English` | Read the `fr-CH` and `de-AT` rows carefully. `fr-CH` has no templates of its own, so it behaves exactly like `fr`. `de-AT` has neither a specific nor a neutral set, so it behaves exactly like `en`. **A wholly unmatched culture is not an error and produces no marker in the response** — it silently returns the default culture's text. :::note Instance reuse is a library detail Those four tests assert instance _identity_ — `de` returns the very same `Localizer` object as `en`. In Agility that optimisation does not fire, because one of Core's template providers contributes a set for every culture, so each requested culture gets its own localizer object chained to its parent. The observable per-tag behaviour in the table above is unchanged; only the object graph differs. ::: ## The Behaviour Nobody Expects **A template found in a fallback culture is expanded against the culture you asked for, not the culture it was found in.** Nested tags in the template body are resolved by the localizer that received the request. So a template can live in English while every word it splices in comes from French. Given: ```text title="en.Base.txt" XYZ=ABC {DEF} GHI DEF=JKL ``` ```text title="fr-FR.Base.txt" DEF=PQR ``` Requesting `XYZ` in `fr-FR` returns: ```text ABC PQR GHI ``` The `XYZ` template exists only in English. Its `{DEF}` placeholder is nonetheless resolved by the `fr-FR` localizer, which has its own `DEF`. This is the mechanism that lets Agility ship one English sentence structure and have every embedded noun localize correctly. It is also the mechanism that produces mixed-language output when a culture translates a noun but not the sentence around it — which is working as designed, not a bug. The same rule applies to instance-wide overrides: | Tag | `en` | `fr` | `fr-FR` | | ----- | ---- | ---- | ------- | | `DEF` | `fed` (override) | `fed` (inherited) | `PQR` (culture wins) | | `XYZ` | `ABC fed GHI` | `ABC fed GHI` | `ABC PQR GHI` | ## Specificity Does Not Cross Cultures Worth repeating from [Tag resolution](/apis/loc-2-v1/tag-resolution#the-search-order-across-the-fallback-chain), because it is the most common cause of "my translation is being ignored": The term-stack walk completes **entirely within one culture** before the next culture is consulted. A generic `Plural` in `fr-FR` beats a specific `Plural'Story` in `en`. If a French user is seeing a French generic plural where you expected an English irregular one, that is why — and the fix is to add the specific entry to the French set, not the English one. ## Checking What a Culture Actually Returns ```bash title="Compare two cultures in two requests" curl "https://www.example.com/VersionOne/loc-2.v1?[Story,Plural'Story,Scope]" \ -H "Accept-Language: en-US" curl "https://www.example.com/VersionOne/loc-2.v1?[Story,Plural'Story,Scope]" \ -H "Accept-Language: fr-FR" ``` Because a wholly unmatched culture is indistinguishable from a matched one in the response body, diffing two cultures like this is the practical way to tell whether a translation set is installed at all. If every value is identical, either the culture has no templates or the tags have no translations — the API cannot tell you which. Pair this with [`noloc`](/apis/loc-2-v1/noloc) to separate "not translated" from "not localized at all". ## Related Topics - [Requests and responses](/apis/loc-2-v1/requests-and-responses#choosing-a-culture) — the `Accept-Language` contract - [Template sets and override order](/apis/loc-2-v1/template-sets) — how a culture's templates are assembled - [Debugging with `noloc`](/apis/loc-2-v1/noloc) --- # Template Sets and Override Order - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/template-sets - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/template-sets.md - Source: apis/loc-2-v1/template-sets.md - Description: How Base, edition and Company template sets stack, how later sets override earlier ones, how an empty value deletes an entry, and what Agility actually loads. - Tags: api, localization, terminology, configuration ## The Stacking Rule A culture's template map is built by applying several **template sets** in order. Each set is a flat list of `tag=value` templates. A tag defined in a later set replaces the same tag from an earlier one; tags a set does not mention are left alone. ```text Base ──┐ edition set ├─ applied in order, later wins Company ──┘ ``` Using the library's fixtures, where `Base` defines `Story` implicitly (no template, so it resolves to itself), `Scrum` renames it and `Custom` renames it again: | Sets loaded | `Story` | `Plural'Story` | `Plural'Project` | `Fixed` | | ------------------------ | -------------- | --------------- | ---------------- | -------------- | | `Base` | `Story` | `Stories` | `Projects` | `Never Change` | | `Base`, `Scrum` | `Backlog Item` | `Backlog Items` | `Projects` | `Never Change` | | `Base`, `Scrum`, `Custom`| `PBI` | `PBIs` | `Projects` | `Never Change` | Note the third column: overriding `Story` did not touch `Project`. And the fourth: a set that never mentions `Fixed` leaves it exactly as it was, however many sets are stacked on top. ## An Empty Value Deletes, It Does Not Blank This is the sharpest edge in the whole system. ```text title="en.Scrum.txt" Story=Backlog Item ; Deliberately remove the definition of Plural'Story Plural'Story= ``` A template whose value is empty **removes** that tag from the map. It does not set it to the empty string. Removal re-exposes whatever the term-stack walk would have found next. So after that set is applied, `Plural'Story` no longer has a specific entry, the walk falls back to the generic `Plural={1}s`, term 1 is `Story`, which now resolves to `Backlog Item` — and the answer is `Backlog Items`, not `Stories` and not an empty string. An empty translation is not stored as an empty string — it **removes** the tag from the culture's map. :::warning You cannot set a tag to the empty string this way If you genuinely want a label to render as nothing, you cannot express it in a template file — an empty value is a deletion. Removal only affects the culture whose map is being built, so a deleted tag can still be answered by the fallback culture. ::: ## Sets Are per-Culture Sets are loaded per culture, so `fr-FR` is assembled from `fr-FR`'s sets only, then chained to `fr`, then to `en`. A `Company` override written for `en` does not apply to `fr-FR` unless `fr-FR` has nothing of its own to say about that tag — which is just the ordinary [culture fallback](/apis/loc-2-v1/culture-fallback) rule. If no set at all exists for a culture, the whole culture defers to its fallback. ## Instance-Wide Overrides Sit on Top of Everything `LocalizationManager` also accepts a dictionary of overrides that is applied to the **default culture's** localizer after every set has been loaded. Those override the final result of the whole set stack: | Tag | Sets say | Override says | Result | | -------------- | -------------- | ------------- | -------- | | `Story` | `PBI` | `Bone` | `Bone` | | `Plural'Story` | derived | — | `Bones` | | `Plural'Project` | `Projects` | — | `Projects` | Because they land on the default localizer, overrides are inherited by every culture that does not override the tag itself. ## The Set Stack Agility Actually Uses The `Base` / `Custom` pair described in the localization engine's own documentation is the **sample application's** configuration, not Agility's. Agility builds a longer stack, in this order: | # | Provider | Source | Present when | | - | -------- | ------ | ------------ | | 1 | `Base` | `Strings/.Base.txt` | Always | | 2 | `ExtendedTypesOff` | `Strings/.ExtendedTypesOff.txt` | The `Defect` asset type is not visible | | 3 | Edition set | `Strings/..txt` | The instance is licensed for localization; the set is named for the configured meta edition | | 4 | `Company` | `Strings/.Company.txt` | Always attempted; the file is customer-supplied | | 5 | `RemoveNonEnglishTemplateProvider("AttributeDefinition")` | code | Every culture except `en` | | 6 | Terminology overrides | the `Localization` database table, filtered to the culture — written by the [terminology override API](/apis/loc-2-v1/overrides) and by the Terminology admin screen | Always | | 7 | System values | code — `Runtime'MaxUploadMb` | Always | Editions shipped in the box include `Scrum`, `XP`, `AgileUP`, `Catalyst`, `DSDM` and `Team`; the English files ship as `Strings/en.*.txt` in the web application. Two consequences worth internalising: - **`Company` is the in-field override file**, the equivalent of the README's `Custom`. It ships empty (in fact, absent) and is the supported place to rename terminology on disk. - **The Terminology admin screen wins over every file.** It is applied at position 6, after all four file sets. If a rename appears to ignore your `Company` file, check the `Localization` table. - **That table is reachable from the API.** The Terminology screen and [`api/localization` / `api/localizations`](/apis/loc-2-v1/overrides) write the same store, so a terminology change made through the API takes precedence over every file for exactly the same reason. This is the supported way to re-term Agility programmatically. ## Debugging an Override That Is Not Taking Effect Work down this list; it is ordered by how often each cause is the real one. 1. **Something later in the stack overrides it.** Check the Terminology admin screen before anything else. 2. **A more specific tag is winning.** You overrode `Plural`, but `Plural'Story` is still defined. See [Tag resolution](/apis/loc-2-v1/tag-resolution). 3. **You edited the wrong culture's file.** `en.Company.txt` does not affect `fr-FR` if `fr-FR` defines the tag. 4. **The file did not parse.** Wrong encoding, or a missing `=`. See [Template file format](/apis/loc-2-v1/template-file-format). 5. **A stale cache.** See [Hot reload and caching](/apis/loc-2-v1/hot-reload). ## Related Topics - [Template file format](/apis/loc-2-v1/template-file-format) — the syntax of a set file - [Hot reload and caching](/apis/loc-2-v1/hot-reload) — making an edit take effect - [Tag resolution](/apis/loc-2-v1/tag-resolution) - [Culture fallback](/apis/loc-2-v1/culture-fallback) --- # Template File Format - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/template-file-format - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/template-file-format.md - Source: apis/loc-2-v1/template-file-format.md - Description: The text file format behind Agility localization template sets: naming, UTF-8 encoding, comments, escaping, multi-line values and deletions. - Tags: api, localization, configuration, reference ## File Naming ```text ..txt ``` Both parts are matched exactly against the culture name and the set name, so `fr-FR.Base.txt` serves the `fr-FR` culture and `fr.Base.txt` serves `fr`. A missing file is not an error — the set simply contributes nothing for that culture. In Agility these live in the web application's `Strings` folder, alongside the shipped `en.Base.txt`, `en.Scrum.txt` and friends. The set names Agility looks for are listed in [Template sets](/apis/loc-2-v1/template-sets#the-set-stack-agility-actually-uses). ## Encoding **Files must be UTF-8.** A byte-order mark is optional, but any other encoding is a hard failure — the reader is constructed with throw-on-invalid-bytes, so a Latin-1 file raises a decoder exception rather than silently mangling accented characters. | Encoding | Result | | -------- | ------ | | UTF-8 without BOM | Works | | UTF-8 with BOM | Works | | ANSI / code page | Throws `DecoderFallbackException` | ## Syntax One template per line, `tag=value`. ```text title="Every rule in one file" ;Comment=Lines starting with semicolon are ignored LongText=First line_ Second line Plural={1}s Plural'Story=Stories Command={1} {2} Fixed=Never Change This==has==equal==signs=Equal-sign free Deleted= ``` ### Comments A line whose **first character** is `;` is skipped entirely. There are no end-of-line comments — a `;` anywhere else is ordinary text. Because the sample line above is a comment, the tag `Comment` is never defined, and resolving it returns `Comment`. ### The Separator The tag ends at the first `=` that is not doubled. Everything after it, to the end of the line, is the value — including further `=` characters, which need no escaping on the value side. A line with no `=` at all is skipped, which is how blank lines are tolerated. ### Escaping `=` In a Tag Double it. `==` in the tag portion means a literal `=`. ```text This==has==equal==signs=Equal-sign free ``` defines the tag `This=has=equal=signs`. ### Multi-Line Values A trailing underscore continues the value onto the next line, and the line break is preserved in the output. ```text LongText=First line_ Second line ``` resolves to: ```text First line Second line ``` Continuations chain: each line that ends with `_` pulls in one more. Note the consequence — a value cannot end with a literal underscore. ### Empty Values Delete ```text Plural'Story= ``` removes `Plural'Story` from this culture's map rather than setting it to an empty string. This is covered in detail, with the reason it matters, in [Template sets](/apis/loc-2-v1/template-sets#an-empty-value-deletes-it-does-not-blank). ### Braces in Values `{` and `}` introduce [nested tags](/apis/loc-2-v1/nested-tags). To emit a literal brace, double it: `{{` and `}}`. ### Whitespace Neither the tag nor the value is trimmed at parse time, so leading whitespace before a tag becomes part of the tag and will not match. Trailing whitespace on a value is harmless because resolution trims the finished string — but only at its edges. Keep lines flush-left. ## Ordering Within a File Order does not matter for lookup: the file is loaded into a map, and [tag resolution](/apis/loc-2-v1/tag-resolution) is driven by tag specificity, not file position. Order only matters between *files*, per [the set stack](/apis/loc-2-v1/template-sets#the-stacking-rule). A duplicate tag later in the same file simply wins. ## Templates That Do Not Come from Files Two of Agility's template sources are not files at all and therefore have no file format: - **Terminology overrides** are rows in the `Localization` database table (`Language`, `Key`, `Value`), filtered to the requested culture. Rows with an empty `Value` are excluded by the query, so the delete-by-empty-value trick is not available there. - **System values** are computed in code — the only one is `Runtime'MaxUploadMb`. They obey the same override rules as files; only the storage differs. ## Related Topics - [Template sets and override order](/apis/loc-2-v1/template-sets) - [Hot reload and caching](/apis/loc-2-v1/hot-reload) — making an edited file take effect - [Nested tag substitution](/apis/loc-2-v1/nested-tags) --- # Hot Reload and Caching - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/hot-reload - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/hot-reload.md - Source: apis/loc-2-v1/hot-reload.md - Description: How Agility picks up edited localization template files without a restart, and the ETag caveat that can hide the change from HTTP clients. - Tags: api, localization, configuration, caching ## Editing Templates on a Running Instance Localization template files can be edited while Agility is running. No restart, no recycle, no rebuild. A file system watcher monitors the `Strings` folder for `*.txt`, on both last-write and file-name changes. When any watched file is created, changed, renamed or deleted, the current localization manager is discarded. The next tag resolution lazily rebuilds it, re-reading every template set from disk. The rebuild is wholesale: every culture's localizer is rebuilt from scratch, and the per-tag resolution caches go with it. Editing one line in `en.Company.txt` therefore invalidates French too, which is correct — English is the fallback for everything. ### What Is *Not* Watched | Source | Hot reloaded? | | ------ | ------------- | | `Strings/*.txt` template files | Yes, automatically | | Terminology overrides in the `Localization` table | Not by the watcher — the admin screens that write those rows trigger a refresh explicitly | | System values (`Runtime'MaxUploadMb`) | Recomputed on rebuild | | The `NoLoc` setting | No — read once at startup. See [Debugging with `noloc`](/apis/loc-2-v1/noloc) | ## The ETag Caveat :::danger A stale ETag can hide a template change from clients `loc-2.v1` responses to `GET` and `HEAD` carry `Cache-Control: public, max-age=3600` and an `ETag`. The ETag is derived from the newest last-write timestamp in the `Strings` folder — but it is **computed once per application lifetime and memoized in a static field that the reload path does not clear**. So after a hot reload: - The server resolves tags correctly and returns the new text to any client that actually asks. - But a client that sends `If-None-Match` with the previously issued ETag gets `304 Not Modified` and keeps its old copy, indefinitely. - Intermediate caches similarly hold the old body for up to an hour on `max-age` alone. ::: ### Working Around It If you are verifying a terminology change over HTTP: ```bash title="Bypass the conditional request and the shared cache" curl -sS "https://www.example.com/VersionOne/loc-2.v1?[Story,Plural'Story]" \ -H "Accept-Language: en-US" \ -H "Cache-Control: no-cache" \ -H "Pragma: no-cache" ``` Send no `If-None-Match` header at all. If you are testing through a browser or a client library that adds one automatically, add a throwaway element to the tag list to change the URL, or use `POST`, whose responses are marked `no-cache`: ```bash title="POST is never conditionally cached" curl -X POST "https://www.example.com/VersionOne/loc-2.v1" \ --data-urlencode '["Story","Plural'"'"'Story"]' ``` For end users on a deployed instance, an application restart clears the static ETag and is the reliable remedy. ## Cache Layers, End to End There are four caches between a template file and rendered text. When a change does not appear, walk them in order: | # | Cache | Cleared by | | - | ----- | ---------- | | 1 | Per-tag resolution cache inside each localizer | Any add/remove — i.e. every rebuild | | 2 | The localization manager itself | The file watcher, or an explicit refresh | | 3 | The static `ETag` on the HTTP handler | Application restart only | | 4 | Browser / proxy / CDN caches | `max-age=3600` expiry, or a cache-busting URL | Caches 1 and 2 are reliable. Caches 3 and 4 are where "I changed the file and nothing happened" almost always lives. `~/loc-2.img` is registered to the same handler as `~/loc-2.v1`, which gives you a second URL for the identical payload — useful as a crude cache-buster for layer 4. ## Vary and per-Culture Caching Responses set `Vary` to include `Accept-Language`, so a shared cache will not serve French text to an English client. This is correct but easy to defeat: if your client omits `Accept-Language` entirely, it participates in the no-language cache entry and receives invariant-culture — i.e. English — output. Always send the header explicitly. ## Related Topics - [Requests and responses](/apis/loc-2-v1/requests-and-responses#caching) - [Template sets and override order](/apis/loc-2-v1/template-sets#debugging-an-override-that-is-not-taking-effect) - [Debugging with `noloc`](/apis/loc-2-v1/noloc) --- # Debugging with noloc - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/noloc - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/noloc.md - Source: apis/loc-2-v1/noloc.md - Description: Turn localization off to see raw localization tags instead of localized text — the fastest way to find out which tag drives a piece of UI or an API label. - Tags: api, localization, troubleshooting, debugging :::tip The single most useful trick in this section `noloc` makes the localizer return every tag verbatim instead of translating it. Suddenly every label in the product tells you its own tag — which is exactly the information you need in order to override it, translate it, or request it from `loc-2.v1`. ::: ## The Problem It Solves Localization is a one-way function. A screen shows **Backlog Items**; you want to rename it. To do that you need the tag, but the tag is nowhere in the output: ```json title="Normal output — informative for users, useless for debugging" { "Plural'Story": "Backlog Items", "Command'Delete'Story": "Remove Backlog Items" } ``` Worse, the mapping is not one-to-one. **Backlog Items** on one screen and **Backlog Items** on another may come from different tags, and a single visible string may be assembled from several tags via [nested substitution](/apis/loc-2-v1/nested-tags). Guessing the tag from the text is not a reliable technique. With localization disabled, the localizer becomes the identity function and the tags surface directly: ```json title="With localization disabled — every value is its own tag" { "Plural'Story": "Plural'Story", "Command'Delete'Story": "Command'Delete'Story" } ``` Now do the same thing in the product UI and read the tags straight off the screen. ## Turning It on in Agility Agility reads a `NoLoc` application setting. It ships commented out: ```xml title="user.config" ``` Uncomment it, set it to `true`, and restart the application. :::warning Read the two constraints before you use this - **It is server-wide.** Every user of the instance sees raw tags, not just you. Never enable it on production or on a shared environment anyone is relying on. - **It is read once at startup**, into a static field. Toggling it requires an application restart in both directions, and it is not affected by [hot reload](/apis/loc-2-v1/hot-reload). ::: Use it on a local instance. ## Worked Example: Finding the Tag Behind a Label Suppose the Delete action on a Story reads **Remove Backlog Items** and you have been asked to change the word *Remove*. **1. Confirm what you get normally.** (The values below are from an illustrative instance where the `Story` asset type has been renamed to `Backlog Item`; yours will differ.) ```bash curl -sS "https://dev.example.com/VersionOne/loc-2.v1?[Command'Delete'Story]" \ -H "Accept-Language: en-US" ``` ```json { "Command'Delete'Story": "Remove Backlog Items" } ``` **2. Enable `NoLoc` and restart.** Then reload the screen. Every label now renders as its tag, so the button reads `Command'Delete'Story`. That is the answer — but the endpoint will confirm it: ```bash curl -sS "https://dev.example.com/VersionOne/loc-2.v1?[Command'Delete'Story]" \ -H "Accept-Language: en-US" ``` ```json { "Command'Delete'Story": "Command'Delete'Story" } ``` **3. Turn `NoLoc` back off and restart.** Now work out *which* template is actually responsible, because it is probably not the tag you just found. Ask for the pieces the tag is built from: ```bash curl -sS "https://dev.example.com/VersionOne/loc-2.v1?[Command'Delete'Story,Plural'Story,Story]" \ -H "Accept-Language: en-US" ``` ```json { "Command'Delete'Story": "Remove Backlog Items", "Plural'Story": "Backlog Items", "Story": "Backlog Item" } ``` Read that top to bottom and the structure falls out: - `Command'Delete'Story` has no template of its own. The [term-stack walk](/apis/loc-2-v1/tag-resolution) lands on `Command'Delete`, whose body is `Remove {Plural'2}`. - `{Plural'2}` splices in term 2 of the original tag — `Story` — giving the nested tag `Plural'Story`, which resolves to `Backlog Items`. :::note Do not probe the prefixes directly It is tempting to also request `Command'Delete` and `Command` to "see the template". Don't — you will get misleading answers, because a template is expanded against whatever tag you asked for. `Command'Delete` has no term 2, so `Remove {Plural'2}` becomes `Remove {Plural'}`, which resolves through the generic `Plural={1}s` with an empty term and yields `Remove s`. Likewise a generic `Command={1} {2}` asked for as bare `Command` yields an empty string. Neither result is a bug, and neither tells you anything about the tag you actually care about. Probe the real tag, plus the nested tags it references. ::: So to change *Remove*, override `Command'Delete`, not `Command'Delete'Story`. Overriding the longer tag would work for Stories and quietly leave every other asset type alone — almost certainly not what was wanted. **4. Apply the override** in the Terminology admin screen or in `.Company.txt`, per [Template sets](/apis/loc-2-v1/template-sets#the-set-stack-agility-actually-uses). ## Finding Text That Was Never Localized at All The technique has a second use, which is what the library README highlights: with localization off, **anything still rendering as normal English prose is hard-coded**. Localized strings degrade to terse internal tags like `Command'Delete'Story`; a hard-coded literal looks exactly the same as it did before. Scanning a screen with `NoLoc` on is therefore a fast audit for text that has been missed by localization — the sentences that still read normally are the bugs. ## Related Mechanisms `NoLoc` is not the only switch, and they are easy to confuse. | Mechanism | Scope | Where | | --------- | ----- | ----- | | `NoLoc` application setting | Server-wide, until restart | Agility — `Common/_user.config` | | `V1.Config.noloc` | The browser client's own resolver | Agility's browser client | | `?noloc` query-string parameter | One HTTP request | **Only** the localization engine's sample application | :::warning `?noloc` does not work against Agility The per-request `?noloc` query-string parameter is a feature of the localization engine's **sample application**, which swaps in a no-op resolver for the duration of the request. Agility does not implement it. Appending `?noloc` to a `loc-2.v1` URL will be parsed as a **tag** and echoed back to you as `{"noloc": "noloc"}` — which looks deceptively like it worked. ::: The client-side `V1.Config.noloc` short-circuits Agility's browser-side localization resolver only. It does not affect `loc-2.v1` responses, which are produced on the server. ## Related Topics - [Tag resolution](/apis/loc-2-v1/tag-resolution) — reading the term stack you just uncovered - [Nested tag substitution](/apis/loc-2-v1/nested-tags) — why one label needs several tags - [Template sets and override order](/apis/loc-2-v1/template-sets) — where to put the override - [Troubleshooting](/troubleshooting) --- # Terminology Overrides - URL: https://docs.digital.ai/agility-api/apis/loc-2-v1/overrides - Markdown: https://docs.digital.ai/agility-api/apis/loc-2-v1/overrides.md - Source: apis/loc-2-v1/overrides.md - Description: api/localization and api/localizations — read and write the terminology overrides that sit at the top of the localization resolution stack. - Tags: api, localization, terminology, overrides [`loc-2.v1`](/apis/loc-2-v1) is the read side of localization. This is the write side. The [resolution stack](/apis/loc-2-v1/template-sets) ends with a terminology store that **beats every template file**. These two endpoints are how entries get into that store — they are the supported way to re-term Agility through the API, and what they write is what `loc-2.v1` subsequently serves. | Endpoint | Scope | Privilege | | --- | --- | --- | | `api/localization` | One key at a time | Authenticated | | `api/localizations` | Batch read and write | **`ManageConfig`** | ## One Key at a Time — `api/localization` A single route handling four verbs. ```http GET ~/api/localization?key=&language= POST ~/api/localization?key=&value=&language= PUT ~/api/localization?key=&value=&language= DELETE ~/api/localization?key=&language= ``` `language` is optional and defaults to the two-letter language of the current UI culture. **`POST` and `PUT` do the same thing** — both upsert. There is no create-versus-replace distinction. `DELETE` removes the override, restoring whatever the template stack resolves to. Both writes refresh the localizer immediately. :::warning `GET` returns a bare string, and a missing override is not a 404 The response body is the override text itself — not JSON, not an object. When no override exists, the endpoint returns **200 with an empty body**, not a 404. An empty response is therefore ambiguous: it means either "no override is set" or "an override is set to the empty string". If that distinction matters, use the batch endpoint, which returns a keyed object where an absent key is distinguishable from an empty value. ::: ## Batch — `api/localizations` Note the **plural**. Different route, different privilege, different shape. Requires the **`ManageConfig`** privilege, which is stricter than the singular route. ```http GET ~/api/localizations POST ~/api/localizations ``` :::note Both verbs take a request body, including `GET` This is unusual and easy to miss. `GET` here is a read that takes a JSON body describing which keys to read, not a plain URL fetch. Some HTTP clients and proxies will not send a body on a `GET`; if reads come back empty for no obvious reason, that is the first thing to check. ::: ### Request ```json { "lang": "en", "keys": ["Story", "Defect"], "overrides": [ { "key": "Story", "value": "Backlog Item", "act": "set" }, { "key": "Defect", "act": "delete" } ] } ``` | Field | Description | | --- | --- | | `lang` | Language to operate on. Defaults to the current UI culture's two-letter language | | `keys` | Keys to read back | | `overrides` | Changes to apply. `POST` only | | `overrides[].act` | `set` or `delete`. **Defaults to `set`** when omitted | ### Response ```json { "lang": "en", "deleted": ["Defect"], "overridesByKey": { "Story": "Backlog Item" } } ``` ## Unknown Keys Are Rejected `api/localizations` **validates the shape of the payload strictly**. Any top-level key that is not `lang`, `overrides` or `keys` is an error. | Error message | Cause | | --- | --- | | `You must supply a payload.` | Empty body | | `Unknown payload at ` | An unrecognised top-level key | | `overrides must be an array` | `overrides` is not an array | | `Values must be strings` | A non-string `value` | | `act must be "set" or "delete"` | A non-string `act` | | `A value must be supplied when setting` | `act: "set"` with no `value` | All surface as **400**, carrying `{ "errorMessage": … }`. Any unexpected server-side exception is converted to a 400 in the same shape, so a 400 here is not always a validation failure. :::tip Strictness is not consistent across Agility Two endpoints reject unknown keys — this one and [`api/webhook`](/apis/webhooks/create). Others, including the [Bulk API](/apis/asset-bulk), tolerate them. Do not carry an assumption either way between surfaces. A payload with a stray field that works against one endpoint can be a hard 400 against another. ::: ## Related - [Localization overview](/apis/loc-2-v1) — the read side - [Template sets](/apis/loc-2-v1/template-sets) — where overrides sit in the resolution stack - [Hot reload](/apis/loc-2-v1/hot-reload) — why a change may not be visible to clients immediately --- # Field Visibility - URL: https://docs.digital.ai/agility-api/apis/field-visibility - Markdown: https://docs.digital.ai/agility-api/apis/field-visibility.md - Source: apis/field-visibility/index.md - Description: Discover which attributes are appropriate to show a user for a given asset type, scope and licence — api/visibleFields, api/visibleFieldsByAsset, api/customFields and api/canbeimportant. - Tags: api, fields, visibility, metadata Four small endpoints answer a question [`meta.v1`](/apis/meta-v1) deliberately does not: **of all the attributes that exist on an asset type, which ones should this user actually be shown?** `meta.v1` describes the schema — every attribute the type has, everywhere, for everyone. That is the right answer for a client that needs the full model, and the wrong answer for a client building a form or a grid. What is appropriate to display depends on the workspace (Scheme) configuration for the project, on licensing, and on which fields an administrator has chosen to display. These endpoints apply those rules. See [what is left out](#what-is-left-out) for the gaps. ## The Endpoints | Endpoint | Method | Answers | | --- | --- | --- | | [`api/visibleFields`](/apis/field-visibility/visible-fields) | `GET` | Which fields to show for these asset types, in these scopes? | | [`api/visibleFieldsByAsset`](/apis/field-visibility/visible-fields#by-asset) | `GET` | Which fields to show for *this specific asset*? | | [`api/customFields`](/apis/field-visibility/custom-fields) | **`POST`** | Which *custom* fields are visible for this asset? | | [`api/canbeimportant`](/apis/field-visibility/can-be-important) | `POST` | Can each of these attributes be flagged important? | :::warning `api/customFields` is a **`POST`** despite reading like a `GET` — a `GET` returns `405`. ::: ## Columns and Attributes — the Distinction Behind These Endpoints What users see are **columns**; what the API models are **attributes** — and visibility flows between them in a loop worth understanding: - A **column** is a user-visible projection of asset attributes — what appears in asset detail views, grids and other views. A column can span **multiple related assets and attribute definitions** at once; a few special columns project no attribute at all. - **Visibility configuration is recorded against attributes**, per project through the workspace configuration or globally. Two rules sit above the configuration: unlicensed attributes are always hidden, and attributes the system requires are always shown. - **A column's visibility is computed from its parts**: a column hides when licensing says so, when every asset type it spans is hidden, or when an attribute it projects is hidden. Columns that track one attribute across several types are gentler — hidden only when *all* their attributes are — so hiding one type's copy of a field does not hide its siblings'. - **These endpoints close the loop**: they walk the columns this user would see and answer in attribute terms — which attributes underlie the visible columns. One more word you will meet: **field**. In conversation (and in the admin UI's "display fields"), *field*, *column* and *attribute* get used interchangeably — but a field is purely a UX concept, the presentation of a column or attribute on screen, potentially for editing. It has no counterpart in the domain model, which knows only attributes and the columns that project them. ## What Is Left Out The filtering these endpoints apply is narrower than "everything an administrator configured", and the omissions are deliberate. - **Only the simplest kinds of columns are considered** — those that project a single attribute directly, plus a few special-purpose ones. A column rendered by more complex machinery is skipped, as are columns built by joining across attributes. That's a known gap, not a bug. - An attribute is included if it can be displayed on the display-fields page **or** is required by the system. - Results are de-duplicated **by attribute name** and returned sorted by name. Treat the result as "safe to show", not as "the complete set of fields this user could ever see". ## Authentication All four require authentication except `api/canbeimportant`, which carries no authorization attribute at all — see [that page](/apis/field-visibility/can-be-important) for what that does and does not imply. ## Related - [Meta API](/apis/meta-v1) — the full schema, unfiltered - [Attribute types](/reference/attribute-types) - [Attribute paths](/reference/query-grammar/attribute-paths) — addressing attributes in queries --- # Visible Fields - URL: https://docs.digital.ai/agility-api/apis/field-visibility/visible-fields - Markdown: https://docs.digital.ai/agility-api/apis/field-visibility/visible-fields.md - Source: apis/field-visibility/visible-fields.md - Description: api/visibleFields and api/visibleFieldsByAsset — the attribute names appropriate to display for an asset type in a scope, or for one specific asset. - Tags: api, fields, visibility Two `GET` endpoints returning attribute **names** that are appropriate to display. They differ only in how you identify what you are asking about: by asset type and scope, or by a specific asset. Both apply the filtering described in the [overview](/apis/field-visibility) — a narrower set than everything `meta.v1` reports. ## `api/visibleFields` ```http GET ~/api/visibleFields?scopeOid=Scope:0&assetTypeToken=Story&assetTypeToken=Defect ``` **Both parameters repeat.** Pass `assetTypeToken` once per asset type, and `scopeOid` once per scope. This is a plain repeated-key query string, not a comma-separated list. | Parameter | Repeatable | Description | | --- | --- | --- | | `scopeOid` | yes | Scope OID tokens providing the context for visibility | | `assetTypeToken` | yes | Asset types to report on | ### Response An array with one entry per requested asset type. **Property names are camelCase**, which is worth noting because the server-side model declares them capitalised — the JSON formatter lowercases them on the way out. ```json [ { "assetType": "Story", "fields": ["Estimate", "Name", "Number", "Scope", "Status"] }, { "assetType": "Defect", "fields": ["Name", "Number", "Priority", "Scope", "Status"] } ] ``` `fields` is sorted by name and de-duplicated. :::danger Do not mix global and non-global asset types Some asset types are *global* — they have no scope context. Requesting a mixture returns **400** with: ```text Cannot mix global and non-global asset types. ``` The rule is all-or-nothing: - **Every** requested type is global → the scope context is ignored entirely and a global context is used. `scopeOid` has no effect. - **No** requested type is global → a multi-scope context is built from the `scopeOid` values. - **A mixture** → 400. Split the request in two if you need both. ::: ## `api/visibleFieldsByAsset` \{#by-asset\} ```http GET ~/api/visibleFieldsByAsset?oidToken=Story:1234 ``` | Parameter | Description | | --- | --- | | `oidToken` | OID token of the asset to report on | The server resolves that asset's Scope and uses it as the visibility context, so you do not supply a scope yourself. ### Response A flat array of attribute names — note this is a **different shape** from `api/visibleFields`, which wraps each list in an object with its asset type: ```json ["Estimate", "Name", "Number", "Scope", "Status"] ``` ## Custom Fields Are Included Both endpoints include custom fields once they are configured for display in the scope. This is the main reason to call them rather than reasoning from `meta.v1` alone: a custom field exists in the schema regardless, but whether it should be shown depends on workspace configuration that only these endpoints apply. If you want *only* the custom fields, use [`api/customFields`](/apis/field-visibility/custom-fields) — but note it is a `POST`. ## Related - [Field visibility overview](/apis/field-visibility) — what the filtering does and does not cover - [Custom fields](/apis/field-visibility/custom-fields) - [Meta API](/apis/meta-v1) --- # Custom Fields - URL: https://docs.digital.ai/agility-api/apis/field-visibility/custom-fields - Markdown: https://docs.digital.ai/agility-api/apis/field-visibility/custom-fields.md - Source: apis/field-visibility/custom-fields.md - Description: api/customFields — the custom fields visible for a given asset. A POST endpoint despite reading nothing but metadata. - Tags: api, fields, custom-fields, visibility Returns the **custom** fields visible for one asset — the subset of [visible fields](/apis/field-visibility/visible-fields) that an administrator added, rather than those Agility ships with. ```http POST ~/api/customFields?oidToken=Story:1234 Content-Length: 0 ``` | Parameter | Where | Description | | --- | --- | --- | | `oidToken` | query string | OID token of the asset to report on | No request body is required, but a `Content-Length` header is — see below. ## Response ```json [{ "name": "Custom_BusinessUnit" }, { "name": "Custom_RiskTier" }] ``` An array of objects each carrying a single lowercase `name`, rather than an array of bare strings. That differs from [`api/visibleFieldsByAsset`](/apis/field-visibility/visible-fields#by-asset), which returns bare strings for the equivalent question over all fields. :::danger This is a `POST`. A `GET` returns 405 Everything about this endpoint suggests `GET`: it reads metadata, changes nothing, takes its only parameter in the query string, and sits directly beside two `GET` siblings. It is nonetheless `POST`-only. | Method | Result | | --- | --- | | `GET` | **405 Method Not Allowed** | | `POST` | Reaches the authorization check | | `PUT`, `DELETE` | 405 | The cause is a routing convention, not a design decision. ASP.NET Web API infers the accepted verb from the action-method name; the two siblings are declared with an explicit `[HttpGet]` attribute, while this action is named `Index` — which matches no `Get*` or `Post*` prefix and therefore falls through to `POST`. Because there is no request body, IIS may answer **411 Length Required** before the request reaches the application. Send `Content-Length: 0` explicitly if your client does not. `GET api/customFields` returns 405 while `GET api/visibleFieldsByAsset` returns 401 — the difference is the verb, not the route or the authorization gate. ::: ## Filtering A field is returned when it is a custom column, is visible in the asset's context, and its term is itself visible. Results are de-duplicated by term. The asset's Scope is resolved server-side and used as the visibility context, so two assets of the same type in different projects can legitimately return different custom fields. ## Related - [Visible fields](/apis/field-visibility/visible-fields) — all displayable fields, not just custom ones - [Field visibility overview](/apis/field-visibility) --- # Can Be Important - URL: https://docs.digital.ai/agility-api/apis/field-visibility/can-be-important - Markdown: https://docs.digital.ai/agility-api/apis/field-visibility/can-be-important.md - Source: apis/field-visibility/can-be-important.md - Description: api/canbeimportant — asks, for a batch of attribute definitions, which ones can be flagged as important. Unknown tokens are silently dropped. - Tags: api, fields, metadata Given a batch of attribute-definition tokens, reports which of them can be marked *important*. ```http POST ~/api/canbeimportant Content-Type: application/json ["Story.Name", "Story.Estimate", "Story.Description"] ``` The request body is a **bare JSON array of strings**, not an object wrapping one. ## Response An object keyed by attribute-definition token: ```json { "Story.Name": true, "Story.Estimate": true, "Story.Description": false } ``` :::warning Unknown tokens are dropped silently — check the key count A token the metadata model does not recognise is **omitted from the response** rather than reported. There is no error, no `null`, and no indication that anything was ignored — the response simply has fewer keys than the request had entries. A typo is therefore indistinguishable from a legitimate `false` unless you look for it. The only reliable check is to compare the number of keys returned against the number of tokens sent, or to look up each token you asked about rather than iterating the response. Posting `["Story.Name", "Story.Estimate", "Story.NotARealAttribute"]` returns exactly two keys, with no mention of the third. ::: ## Authentication **This endpoint carries no authorization attribute.** Unlike its neighbours in [field visibility](/apis/field-visibility), the controller declares neither `[AuthorizeApi]` nor any equivalent, and a live `26.2` instance answers **200 to an unauthenticated request**. Treat that as observed behaviour on a default configuration rather than as a guarantee. What a deployment actually exposes can be changed at layers the application source does not describe — a reverse proxy or a gateway may authenticate ahead of it. If it matters to your integration, verify against the instance you are targeting rather than relying on this page. What it returns is metadata about attribute definitions, not customer data. ## Related - [Field visibility overview](/apis/field-visibility) - [Attribute types](/reference/attribute-types) --- # Rooms & Panels - URL: https://docs.digital.ai/agility-api/apis/rooms - Markdown: https://docs.digital.ai/agility-api/apis/rooms.md - Source: apis/rooms/index.md - Description: api/teamroom and api/planningroom — configure the panels shown in a Team Room or Planning Room. - Tags: api, rooms, panels, team-room, planning-room Two endpoints supporting Agility's room features. These are **configuration APIs for the Agility UI** rather than general-purpose data APIs — they manage which panels appear in a room and which saved views exist, not the work items inside them. Rooms themselves are ordinary assets: create and configure a `TeamRoom` or `PlanningRoom` through the [data APIs](/apis/rest-1), then use the panel APIs here to arrange what the room shows. That split is deliberate — these endpoints exist so room provisioning can be automated at scale. | Endpoint | Purpose | | --- | --- | | [`api/teamroom`](/apis/rooms/panels) | Panels in a Team Room | | [`api/planningroom`](/apis/rooms/panels) | Panels in a Planning Room | :::note Saved views and room configuration are not published yet The `rooms2`-family endpoints (`api/rooms2views`, `api/rooms2config`) exist but are deliberately undocumented here for now: they are still settling as the new rooms experience builds on them, and we would rather publish a stable contract than revise one. They will appear here when their use stabilizes. ::: ## Authorization Is Stricter Than Read Access The two panel APIs require the **`EditRoom`** right on every route, including the ones that only read. Listing the available panels needs the same permission as installing one. They also require a **room context**, supplied as a `roomContext` parameter, which the server establishes before the action runs. ## The Two Panel APIs Are Similar but Not Interchangeable `api/teamroom` and `api/planningroom` look like the same API twice and are close enough that assuming symmetry is tempting. They differ in three ways, each of which will break a client that assumes otherwise — see [Room panels](/apis/rooms/panels) for the comparison. --- # Room Panels - URL: https://docs.digital.ai/agility-api/apis/rooms/panels - Markdown: https://docs.digital.ai/agility-api/apis/rooms/panels.md - Source: apis/rooms/panels.md - Description: api/teamroom and api/planningroom — list, install, remove and update the panels in a room, including the planning-room panel configuration payload and its perspective override. - Tags: api, rooms, panels, team-room, planning-room Two APIs managing the panels shown in a room: `api/teamroom` for Team Rooms and `api/planningroom` for Planning Rooms. They exist to let panels be provisioned programmatically — spinning up rooms at scale — and are a separate surface from the wiring the product UI itself uses. Every route on both requires authentication, a **`roomContext`** parameter, and the **`EditRoom`** right — including the read routes. :::warning `roomContext` is mandatory everywhere, and the failure is unhelpful Omit it and neither API says "missing roomContext". `api/teamroom` routes return `400` with `{"Exceptions": ["Bad Request", "Reference: "]}` — the GUID is an error-report reference, not data. `api/planningroom` routes return a bare `404`, because the action's required parameter cannot bind. Both mean the same thing: supply `roomContext=`. This applies to `api/teamroom/panels` as well, even though the list it returns is not room-specific — the parameter is demanded before the action runs. ::: ## They Are Not Symmetric The two are close enough to assume they are the same API with a different prefix. They are not. | | `api/teamroom` | `api/planningroom` | | --- | --- | --- | | Available panels | `panels?roomContext=…` — list is not room-specific | `panels?roomContext=…` — per-room flags | | Installed panels | `installedpanels?roomContext=…` | `installedpanels?roomContext=…` | | Install | `installpanel` — no body | `installpanel` — optional configuration body | | Remove identifier | **`type`** (string) | **`panelId`** (GUID) | | Update | — **not available** | `updatepanel` | The differences that break a client assuming symmetry: 1. **Removal identifiers differ.** Team Room removes by panel `type`; Planning Room removes by the `id` GUID returned when the panel was installed. A Team Room holds at most one panel of a given type, while a Planning Room addresses each instance individually (and can hold several of the same type — see `canInstallAnother`). 2. **Only Planning Room panels carry configuration.** Team Room panels have none to update — which is why its install takes no body and there is no `updatepanel`. ## `api/teamroom` ```http GET ~/api/teamroom/panels?roomContext= GET ~/api/teamroom/installedpanels?roomContext= POST ~/api/teamroom/installpanel?roomContext=&type= POST ~/api/teamroom/removepanel?roomContext=&type= ``` Available and installed panels return the same shape; `type` is the value `installpanel` and `removepanel` take: ```json title="GET panels — response (abridged)" [ { "type": "Backlog", "name": "Backlog", "description": "Planning and prioritization of your agile Backlog in a list view.", "isRemovable": false } ] ``` `isRemovable: false` marks the panels every Team Room carries; `removepanel` is for the removable ones. `installpanel` returns **201 Created** with the single installed panel object (not the full list). `removepanel` returns **204 No Content**. ## `api/planningroom` ```http GET ~/api/planningroom/panels?roomContext= GET ~/api/planningroom/installedpanels?roomContext= POST ~/api/planningroom/installpanel?roomContext=&type= POST ~/api/planningroom/removepanel?roomContext=&panelId= POST ~/api/planningroom/updatepanel?roomContext=&panelId= ``` | Route | Success status | | --- | --- | | `installpanel` | 201 Created | | `updatepanel` | **201 Created** | | `removepanel` | 204 No Content | :::note `updatepanel` returns 201, not 200 Updating an existing panel answers **201 Created** rather than 200 OK. That is unusual — 201 normally signals that a new resource was created — and a client that treats 201 as "a new panel now exists" will double-count. Read the response body rather than inferring from the status. ::: ### Listing Panels `panels` describes what could be installed, with per-room flags: ```json title="GET panels — response (abridged)" [ { "name": "EpicBoard", "type": "epic-board", "description": "The panel displays a Portfolio Kanban configurable by panel-specific settings.", "canConfigure": true, "canInstallAnother": true, "alreadyInstalled": true } ] ``` `installedpanels` describes the instances actually in the room, each with its `id` GUID — the identifier `updatepanel` and `removepanel` take — and its configuration: ```json title="GET installedpanels — response (abridged)" [ { "id": "c2a77134-63ab-4282-85ca-94ebe2aa013d", "title": "Epicboard 2", "type": "epic-board", "description": "The panel displays a Portfolio Kanban configurable by panel-specific settings.", "canInstallAnother": true, "canConfigure": true, "useRoomPerspective": false, "perspective": { "project": "Scope:1068", "program": "ScopeLabel:1069", "schedule": "Schedule:1064" }, "isSingleLevel": false, "epicType": null, "url": null } ] ``` ### The Panel Configuration Payload {#panel-configuration} `installpanel` and `updatepanel` accept a JSON body. On install it is **optional** — omit it and the panel takes the room's defaults; supply it and the configuration is validated and applied in the same request. ```json { "title": "Epicboard 2", "perspective": { "project": "Scope:1068", "program": "ScopeLabel:1069", "schedule": "Schedule:1064" }, "epicType": "EpicCategory:246", "isSingleLevel": false, "useRoomDefaults": false, "url": "" } ``` | Field | Meaning | | --- | --- | | `title` | Display title for this panel instance | | `perspective` | The panel's own view slice — `project` (a `Scope` OID), and optionally `program` (a `ScopeLabel` OID) and `schedule` (a `Schedule` OID). This is how a panel looks at a **different** project, program or schedule than the room it lives in | | `useRoomDefaults` | Boolean; use the room's perspective instead of a panel-specific one. Defaults to false. Note the asymmetry: the **request** key is `useRoomDefaults`; responses report it as `useRoomPerspective` | | `isSingleLevel` | Boolean; when a panel-specific perspective is used, exclude items belonging to child projects of the perspective's project. Defaults to false | | `epicType` | Optional `EpicCategory` OID narrowing Portfolio Items to one type; all types when omitted | | `url` | Web panels only — the URL the panel shows | The perspective is stored as room configuration, not as asset attributes, so this page is its schema: none of these fields are discoverable through [`meta.v1`](/apis/meta-v1). ## Everything Is a POST All mutating routes are `POST`, including removal. There is no `DELETE` and no `PUT`. This matches [`api/webhook`](/apis/webhooks), which also deletes via `POST`. ## Related - [Rooms & panels overview](/apis/rooms) --- # Project Roles (api/ProjectRoles) - URL: https://docs.digital.ai/agility-api/apis/project-roles - Markdown: https://docs.digital.ai/agility-api/apis/project-roles.md - Source: apis/project-roles.md - Description: Read which members hold which roles on which projects. A read-only, self-describing endpoint that publishes its own RAML specification. - Tags: api, project-roles, members, raml Reports which members hold which roles on which projects, from either direction — grouped by project, or grouped by member. It is **read-only by design.** There is no create, update or delete here. Role assignments are changed through the [Bulk API](/apis/asset-bulk), which is the only API endpoint intended for that, and are governed by Agility's rights and privileges. ## It Describes Itself Unusually for Agility, this endpoint publishes a **machine-readable specification of itself**: ```http GET ~/api/ProjectRoles ``` returns a complete **RAML 1.0** document as `text/plain` — type definitions, both resources, their query parameters, response schemas and worked examples. Roughly 14 KB. **No authentication is required for the specification**, which makes it usable for client generation and exploration without a token. It returns `200 text/plain` unauthenticated, while the two data resources below return `401`. :::note This is the only self-describing Agility endpoint Nothing else in the API publishes a machine-readable contract of itself. The API surface as a whole has no OpenAPI or RAML description — the only other machine-readable artifacts this site publishes are the [ANTLR query grammars](/reference/query-grammar/ebnf). Do not generalise the pattern: `api/ProjectRoles` describes `api/ProjectRoles` and nothing else. ::: ## Resources Both are `GET` and both require authentication. ### Grouped by Project ```http GET ~/api/ProjectRoles/ByProject?where=Name='New Project'&membersWhere=Nickname='JDoe' ``` | Parameter | Description | | --- | --- | | `where` | Filters the **projects** returned | | `membersWhere` | Filters the **members** within each project | | `page` | Pages the projects, and the members within each project — see [Paging](#paging) | | `sort` | Orders the **projects** — see [Sort](#sort) | Returns `{ total, assets: ProjectMemberRoles[] }`, where each entry pairs a project with the members holding roles on it. ### Grouped by Member ```http GET ~/api/ProjectRoles/ByMember?where=Nickname='JDoe'&projectsWhere=Name='New Project' ``` | Parameter | Description | | --- | --- | | `where` | Filters the **members** returned | | `projectsWhere` | Filters the **projects** within each member | | `page` | Pages the members, and the projects within each member — see [Paging](#paging) | | `sort` | Orders the **members** — see [Sort](#sort) | Returns `{ total, assets: MemberProjectRoles[] }` — the same relationships from the other side. Note the symmetry in the parameter naming: `where` always filters the thing you grouped by, and the other parameter filters what is nested inside it. Give at least one of the two filters. With neither, a resource returns no data: it answers `200 text/plain` with its own section of the [specification](#it-describes-itself). ## Paging `page` takes two pages in one value, separated by a semicolon: ```text page=outerSize,outerStart;innerSize,innerStart ``` - The **outer** page applies to the list you grouped by: projects on `ByProject`, members on `ByMember`. - The **inner** page applies to the nested list inside **each** group, separately for each group. `page=10,0;5,0` returns up to 10 projects, each listing its first 5 members. The inner list is in ID order. - A size without a start means start `0`, so `page=10` is `page=10,0`. ```http GET ~/api/ProjectRoles/ByProject?where=AssetState='64'&page=10,0;5,0 GET ~/api/ProjectRoles/ByProject?where=AssetState='64'&page=10,10;5,0 ``` :::warning Always send an outer page Without `page`, the outer list is **not paged**: every matching project (or member) comes back in one response. The inner list is still capped at **50** per group, so a project with more than 50 members is silently cut short unless you page its members yourself. ::: A `page` value that can't be parsed is not rejected. The segment falls back to `50,0`, so a typo gives you a different page, not a `400`. ### `total` Counts This Response, Not the Matches `total` is the number of groups in this response. It is not the number of projects or members that match `where`, and it can't tell you whether more pages remain: - `ByProject` leaves out any project with no matching member roles. - `ByMember` leaves out any member with no matching project roles when `projectsWhere` is given. So a page can hold fewer groups than its size while more remain. To know how many pages to request, count the matches through [rest-1](/apis/rest-1) with the same `where` and an explicit `needTotal=true`, which returns the total even for a one-row page: ```http GET ~/rest-1.v1/Data/Scope?where=AssetState='64'&page=1,0&needTotal=true ``` Use `Data/Member` for `ByMember`. Send `needTotal=true` every time; its default varies by instance (see [paging totals](/reference/query-grammar/sort-and-paging)). ## Sort `sort` orders the list you grouped by: projects on `ByProject`, members on `ByMember`. It takes one or more attribute names of that type, separated by commas, each optionally prefixed with `-` for descending: ```http GET ~/api/ProjectRoles/ByProject?where=AssetState='64'&sort=-Name,+ID&page=10,0 ``` - The default is `+ID`. - The nested list is not affected: members within a project, and projects within a member, are always in ID order. - An attribute name the server doesn't recognize is ignored without an error, and you get the default order. If your order looks like ID order, check the attribute name. ## Response Shape ```json { "total": 2, "assets": [ { "project": { "oidToken": "Scope:1003", "name": "New Project" }, "memberRoles": [ { "isOwner": true, "member": { "oidToken": "Member:20", "name": "Administrator", "email": "admin@v1.com", "defaultRole": { "oidToken": "Role:1", "name": "System Admin" } }, "role": { "oidToken": "Role:1", "name": "System Admin" } } ] } ] } ``` :::tip `defaultRole` and `role` are different things, and the difference matters Both appear in every response and they answer different questions. - **`member.defaultRole`** is what the UI presents as **Admin Privileges**. It determines the member's **global privileges**, and it is the role they will act as by default when they are given access to a new Scope. - **`role`** is the role that member actually holds **on this specific project**. They frequently differ, and neither implies the other. A member whose `defaultRole` is `Team Member` can hold `Project Lead` on one project; a member whose `defaultRole` is `System Admin` carries those global privileges regardless of what any individual project says. If you are answering "what can this person do here?", read `role`. If you are answering "what can this person do generally, and what will they get on their next project?", read `defaultRole`. ::: `isOwner` flags the project owner, distinct from any role assignment. ## Related - [Bulk API](/apis/asset-bulk) — the supported way to change role assignments - [Meta API](/apis/meta-v1) — `Member`, `Role` and `Scope` schema - [Query grammar](/reference/query-grammar) — the filter syntax `where` accepts --- # Watch (api/watch) - URL: https://docs.digital.ai/agility-api/apis/watch - Markdown: https://docs.digital.ai/agility-api/apis/watch.md - Source: apis/watch.md - Description: Subscribe the calling member to an asset, check whether they are watching it, and list everything they watch. - Tags: api, watch, subscriptions Manages the calling member's asset subscriptions — "watching" an asset so they are notified about it. Everything here is scoped to **the authenticated member**. There is no parameter for acting on another member's watches, and the list endpoint returns only your own. :::info Not the same thing as webhooks `api/watch` is per-member notification within Agility. [Webhooks](/apis/webhooks) are instance-level HTTP callbacks to an external system. They share the word "subscription" and nothing else. ::: All routes require authentication. ## Subscribe or Unsubscribe ```http POST ~/api/watch Content-Type: application/json { "oid": "Story:1234", "action": "Subscribe" } ``` | Field | Required | Description | | --- | --- | --- | | `oid` | yes | OID token of the asset to watch or unwatch | | `action` | yes | `Subscribe` or `Unsubscribe` | `action` is matched **case-insensitively**, so `subscribe`, `Subscribe` and `SUBSCRIBE` all work. ### Response ```json { "oid": "Story:1234", "isWatching": true, "action": "subscribe" } ``` Note the echoed `action` comes back **lower-case** regardless of how you sent it. Do not round-trip it into a case-sensitive comparison. `isWatching` is `true` after a subscribe and `false` after an unsubscribe. ### Errors All are `400`. The validation **wording** varies between versions — key on the status code and the missing field, not on the message text: | Body | Cause | | --- | --- | | `"Missing request body"` | No body sent | | A ModelState object | `oid` or `action` missing — both are `[Required]` | | `"Invalid action"` | `action` is null, or is neither `Subscribe` nor `Unsubscribe` | ## Check One Asset ```http GET ~/api/watch?oid=Story:1234 ``` ```json { "oid": "Story:1234", "isWatching": false } ``` Returns `400` with `"Missing oid"` when the parameter is present but blank (`?oid=`). Omitting the parameter entirely returns `405` instead — a GET with no query routes against the POST-only subscribe endpoint. ## List Everything Watched ```http GET ~/api/watch/list ``` ```json { "watchedOids": ["Story:1234", "Defect:5678"] } ``` The watched OIDs for the calling member. Note the array is wrapped in an object rather than returned bare. ## What a Subscribe Actually Creates A watch is not hidden state — it is ordinary assets, and `api/watch` is a convenience that assembles them so you don't have to know the machinery. A subscribe creates: - a **`Subscription`** — `Name` is a generated title ("Watching Logon (S-01001)"), `EventDefinition` is the watched type's changed event (`Story.Changed`), `Subscriber` is the calling member; - a **`SubscriptionTerm`** in its `FilterTerms` — `AttributeToken: "ID"`, `Value` the watched OID — created *in the subscription's context* so its required pieces default correctly. Both are queryable: ```json { "from": "Subscription", "select": ["Name", "EventDefinition", "FilterTerms.AttributeToken", "FilterTerms.Value"], "where": { "Subscriber": "Member:20" }, "page": { "size": 50, "start": 0 } } ``` Unsubscribing removes them again. Two consequences worth knowing: - Watches are the narrow case of a wider model: the application's subscription editor composes `SubscriptionTerm`s over attributes such as `Scope.ParentMeAndUp`, `Owners`, `Status` and `Team` against any event definition — richer subscriptions than a single-asset watch. - Building the same assets by hand through the Bulk API needs the term created in its subscription's context — the [`#ContextOid`](/apis/asset-bulk/aliases-and-projection) initialization — which is exactly the machinery `api/watch` exists to spare you. ## Related - [Webhooks](/apis/webhooks) — external HTTP callbacks, a different mechanism - [Activity stream](/apis/activity-stream) — what changed on an asset --- # Attachments and Images (attachment.img) - URL: https://docs.digital.ai/agility-api/apis/attachments - Markdown: https://docs.digital.ai/agility-api/apis/attachments.md - Source: apis/attachments.md - Description: Upload and download attachment and image binaries. Two workflows, a family of blob endpoints, and an id format that is not an OID token. - Tags: api, attachments, images, binary, upload Binary content — attachment files, member avatars, images embedded in rich text — does not travel through the asset APIs the way ordinary attribute values do. It has its own family of endpoints, of which `attachment.img` is the one most people need. An attachment is **two things**: an `Attachment` asset holding the metadata, and a blob holding the bytes. You can create both in one request or in two, and the choice matters more than it looks. ## Two Ways to Create an Attachment ### One Request — Inline Base64 The [Bulk API](/apis/asset-bulk) accepts the file content directly on the `Attachment` asset, Base64-encoded: ```json [ { "@newDefect": { "AssetType": "Defect", "Scope": "Scope:0", "Name": "An Example Defect" } }, { "AssetType": "Attachment", "Asset": "@newDefect", "Content": "VGhpcyBpcyB0aGUgY29udGVudCBvZiB0aGUgdGV4dCBmaWxl", "ContentType": "text/plain", "Filename": "sample.txt", "Name": "Sample Text" } ] ``` Note the attachment references the Defect by [alias](/apis/asset-bulk/aliases-and-projection), so both are created in a single call. This is the simpler path, and it is bounded by request size — Base64 inflates the payload by roughly a third, and the whole request is still subject to the server's maximum request length. ### Two Requests — Create, Then Upload the Bytes Create the `Attachment` asset with **empty** content, then `POST` the raw bytes to `attachment.img`: ```json title="1. Create the asset" { "AssetType": "Attachment", "Asset": "Defect:1234", "Content": "", "ContentType": "text/plain", "Filename": "sample.txt", "Name": "Sample Text" } ``` ```http title="2. Upload the bytes" POST ~/attachment.img/5678 Content-Type: application/octet-stream ``` This is the path to use for anything large, and it is what the SDKs do internally. ### `Attachment` Asset Attributes All five are **required** on create: | Attribute | Description | | --- | --- | | `Asset` | The asset the attachment belongs to | | `Name` | Display name | | `Filename` | The file name | | `ContentType` | MIME type | | `Content` | Base64 content, or empty string when uploading separately | A create missing any of them fails with a violation naming one missing attribute — `Violation'Required'AttributeDefinition'Content'Attachment` — and the violations surface **one at a time**, in the order `Asset`, `Content`, `ContentType`, `Filename`, `Name`, so fixing the named attribute can reveal the next. `Content` must be *present* but may be the empty string (the two-request flow depends on exactly that). ## Downloading Attachment Content To read the bytes back, `GET` the blob endpoint by numeric id: ```http GET ~/attachment.img/5678 ``` It streams the content, sets a MIME `Content-Type`, and returns an `ETag`, so a follow-up request with `If-None-Match` is served from cache. This is the path the SDKs and the product UI use, and the one to hand to integrators. If you would rather stay on the data API, `rest-1.v1` can return the content as a Base64 attribute: ```http GET ~/rest-1.v1/Data/Attachment/5678?sel=Content,ContentType,Filename ``` Reserve that for the occasional single attachment. The bytes come back Base64-encoded inside the response body, which inflates them by about a third. :::danger Do not bulk-download content through `query.v1` or `api/asset` Selecting `Content` across many attachments — `query.v1` or `api/asset` with `Content` in the selection — is the wrong tool, even though it "works". Every blob is Base64-encoded inline in one response, so the server holds all of it in memory at once, the payload balloons, and nothing can be HTTP-cached. A single such request can pull tens of megabytes and pressure the whole instance. Download blobs one at a time through `attachment.img/{id}` (cacheable, streamed), and use the data APIs only to discover **which** attachments exist — project `Asset`, `Filename`, `ContentType`, never `Content`, when listing. ::: ## The `attachment.img` Contract ```http GET ~/attachment.img/{id} HEAD ~/attachment.img/{id} POST ~/attachment.img/{id} ``` :::danger `{id}` is the numeric asset ID, not the OID token `attachment.img/Attachment:5678` does **not** work. The handler parses the path segment as a plain integer — digits only, no sign — so you must strip the type prefix and pass `5678`. This is easy to get wrong because every other API surface addresses assets by OID token. If you are extracting the id from a Bulk API `assetsCreated.oidTokens` entry, take the part **after** the colon. ::: | Method | Behaviour | | --- | --- | | `GET` | Download the blob. Returns an `ETag`; a matching `If-None-Match` gets a cached response | | `HEAD` | As `GET`, without the body | | `POST` | Upload, replacing the blob. Responds `text/plain` | | anything else | `405`, with `Allow: GET,HEAD,POST` | **The upload body can be either form.** The handler takes the first uploaded file if the request is a multipart form, and otherwise reads the raw request body. A plain `POST` of the bytes works; so does a file upload. Add `?deleted=1` to operate on an attachment whose asset is deleted. ### Errors | Status | Body | Cause | | --- | --- | --- | | `400` | `Invalid Url` | No id in the path — `attachment.img` with nothing after it | | `400` | `Invalid ID` | The path segment is not a plain integer | | `401` | | Unauthenticated. Every blob endpoint requires authentication | | `404` | `Not Found` | No blob for that id | | `413` | `Maximum Content Length Exceeded` | Upload larger than the server's `maxRequestLength` | :::note A missing blob answers 404 even for a disallowed method The handler checks that the blob exists **before** it checks the HTTP method, so a `DELETE` against a nonexistent id returns `404`, not `405`. You only see `405` when the id resolves to something real. Worth knowing when probing: a `404` does not confirm your method was acceptable. ::: ## The Rest of the Blob Family `attachment.img` has siblings that behave the same way and differ only in what they address: | Endpoint | Holds | | --- | --- | | `attachment.img` | Attachment files | | `image.img` | Images such as member avatars | | `embedded.img` | Images embedded in rich-text fields | | `expressionimage.img` | Images posted in conversations | | `downloadblob.img` | Download only — `GET` | Each also has an **OAuth variant** at `.oauth.img` — `attachment.oauth.img`, `image.oauth.img`, `embedded.oauth.img`, `expressionimage.oauth.img` — which accept delegated authentication. Use the plain form with Basic authentication or an access token, and the `.oauth.` form when presenting an OAuth token. ## Size Limits Are a Server Setting The maximum upload is the ASP.NET `maxRequestLength` for the instance, not an API constant. It differs between deployments, and an on-premise administrator can change it. Do not hardcode a limit — read it from [`config.v1`](/apis/config-v1) as `MaximumAttachmentSize` (bytes), and still handle `413`. ## Related - [Bulk API](/apis/asset-bulk) — creating the `Attachment` asset - [Aliases](/apis/asset-bulk/aliases-and-projection) — attaching to an asset created in the same request - [Attribute types](/reference/attribute-types) — `Blob` is the underlying type --- # Valid Values (api/ValidValues) - URL: https://docs.digital.ai/agility-api/apis/valid-values - Markdown: https://docs.digital.ai/agility-api/apis/valid-values.md - Source: apis/valid-values.md - Description: Ask what values a relation attribute may take - the endpoint the application uses to populate its dropdowns, Team Process included. - Tags: api, valid-values, relations, workflows Answers "what values may this relation take?" — the same question the application answers when it populates a dropdown, honoring the schema's valid-values filters (where the [`$` asset-identity variable](/reference/query-grammar/filter-context#reserved-variables) does its work), the asking context, and [Team Process](/concepts/workflows). Both routes accept GET or POST and require authentication. ## By Relation, with a Context ```text GET ~/api/ValidValues/Story.Status/?context=Scope=Scope:1067 ``` The path segment is the full attribute token. **The trailing slash is required**: the token contains a dot, and without a trailing slash the web server reads `.Status` as a file extension and answers 404 before the route is ever consulted. (The existing-asset form below has no dot in its last segment, so it needs no slash.) | Parameter | Meaning | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `context` | Comma-separated `Name=Oid` pairs feeding the valid-values filter's variables. Omitted entirely, it defaults to `Scope=Scope:0`; an explicitly **empty** `context=` supplies no context at all, and typically returns nothing. | | `sel` | Attributes to return per value; defaults to `Name,ID`. | | `sort` | An attribute of the related type to sort by. | ## For an Existing Asset ```text GET ~/api/ValidValues/Story/1207/Status ``` Evaluates the valid-values filter against the asset's **actual state**, so the answer reflects everything that narrows it — most usefully, a team's own workflow. On an instance whose shared `StoryStatus` list is *Future / In Progress / Done / Accepted*, a story on a team with its own process answers differently: ```text GET ~/api/ValidValues/Story/1207/Status → Ready, Dev, Dev Done, Test, Test Done, Accepted ``` This is the way to ask **"which statuses may this workitem take?"** before a write — the values a Team Process enforces on status changes. An optional `teamoid` parameter supplies the team explicitly. `sel` and `sort` work as above. ## Response Shape The envelope looks like a `rest-1.v1` list — `_type: "Assets"`, `total`, `Assets` with `id` and `Attributes` — and each entry's `href` points at the asset's `rest-1.v1` address: ```json { "_type": "Assets", "total": 4, "Assets": [ { "_type": "Asset", "href": "~/rest-1.v1/Data/StoryStatus/133", "id": "StoryStatus:133", "Attributes": { "Name": { "_type": "Attribute", "name": "Name", "value": "Future" }, "ID": { "_type": "Attribute", "name": "ID", "value": "StoryStatus:133" } } } ] } ``` `pageSize` reports the no-limit sentinel; the endpoint does not page. ## Quick Suggestions (`api/QuickSuggestion`) The same machinery has a lighter sibling, used for type-ahead: ```text GET ~/api/QuickSuggestion/Story:1207/Story.Status/ ``` The first segment is the asset's OID, the second the full attribute token — and the trailing slash is required here for the same dot-in-the-last-segment reason. If the relation defines a **quick-values filter** (a schema-defined shortlist), the response has `type: "Suggestions"`; otherwise it falls back to the valid values, capped at 20, with `type: "ValidValues"`: ```json { "data": { "type": "ValidValues", "data": [{ "_oid": "StoryStatus:133", "name": "Future" }] } } ``` Use `api/ValidValues` when you need the complete, context-correct answer; `api/QuickSuggestion` when you want what the application would offer first in a type-ahead. ## Related Topics - [Workflows and status](/concepts/workflows) — Team Process and the invalid-status violation this endpoint lets you avoid - [Reserved filter variables](/reference/query-grammar/filter-context#reserved-variables) — the `$` the underlying filters are written in - [Attributes](/concepts/attributes) --- # Configuration (config.v1) - URL: https://docs.digital.ai/agility-api/apis/config-v1 - Markdown: https://docs.digital.ai/agility-api/apis/config-v1.md - Source: apis/config-v1.md - Description: Read an instance's effective configuration - attachment size limit, time zone, effort tracking - without authentication. - Tags: api, configuration, instance Reports the instance's effective configuration — the settings an integration would otherwise have to hardcode or guess. Like [`meta.v1`](/apis/meta-v1), it requires **no authentication**; unlike most of the API, it speaks only XML and accepts only `GET`. ```text GET ~/config.v1 ``` ```xml ``` The root element's `version` and `source` attributes identify the build the instance is running — the quickest way to answer "what version is this instance?" programmatically. (A local development build reports `0.0.0.0` and `unknown`, because dev assemblies are not version-stamped.) ## The Settings | Key | Meaning | | --- | --- | | `EffortTracking` | Whether effort (Actuals) tracking is enabled on the instance | | `StoryTrackingLevel` / `DefectTrackingLevel` | Where effort detail is tracked for the type: at the workitem itself (`On`), at its child tasks and tests (`Off`), or both (`Mix`) | | `MaximumAttachmentSize` | The upload limit in **bytes** — the size behind an [attachment upload's `413`](/apis/attachments#size-limits-are-a-server-setting) | | `CapacityPlanning` | The instance's capacity-planning level | | `Scorecard`, `ScorecardSelectiveBreakdown` | Whether the scorecard features are visible | | `Scorecard.Charts.Query.Timeout` | Scorecard chart query timeout, in milliseconds | | `TimeZoneId` | The instance time zone (Windows time-zone ID) | | `TimeZoneDisplayName` | Its display form, with UTC offset | | `TimeZoneName` | The name currently in effect — daylight or standard, depending on the date | Two of these answer questions this documentation repeatedly tells you not to hardcode: - **`MaximumAttachmentSize`** — discover the real upload limit instead of handling `413` blind. - **The time-zone keys** — local timestamps such as `ChangeDate` are in the instance's time zone; this is where you learn which one that is. (For filtering and incremental loads, still [prefer the `…UTC` twins](/concepts/attributes).) ## Related Topics - [Attachments](/apis/attachments) — where the size limit bites - [Attributes](/concepts/attributes) — local vs UTC date attributes - [Meta API](/apis/meta-v1) — the other anonymous discovery endpoint --- # Sync & Connect - URL: https://docs.digital.ai/agility-api/apis/sync - Markdown: https://docs.digital.ai/agility-api/apis/sync.md - Source: apis/sync/index.md - Description: Agility Sync / Agility Connect - the ancillary integration product and the shape of its API - Tags: sync, connect, integrations **Agility Sync** (also appearing as **Agility Connect**) is an ancillary product for organizations that need to synchronize Digital.ai Agility with similar or competing tools — Jira, Azure DevOps and others. It is a **separate platform with its own API**, and that API resembles the Agility asset API in almost nothing: different host, different authentication scheme, different request style. This page summarizes the API's shape so you can recognize it and get oriented; the authoritative reference is the product's own documentation — start from [Getting started with the Agility Sync API](https://docs.digital.ai/agility/docs/get-started/getting-started-with-agility-sync-api). ## How Its API Differs from the Agility API | Aspect | Agility API | Sync / Connect API | | --------------- | ------------------------------------- | ------------------------------------------------------ | | Host | Your Agility instance | The Connect service's own host and port | | Style | Asset/query documents | **Command-style REST**: `GET /api/` | | Authentication | `Authorization: Bearer ` | `Authorization: Token ` (note: *Token*, not *Bearer*) | | Default output | XML or JSON by endpoint | **XML** — send `Accept: application/json` for JSON | ## Getting Oriented on a Connect Instance - `GET /api/version` — verify the API is reachable and see its version - `GET /api` — list every command the API supports; the command list is the real index of capability ## Authentication - **Token** (recommended): `Authorization: Token `, where the token comes from the Connect UI (**My Account**) — or programmatically from `GET /api/get_token` using Basic credentials, if an administrator has enabled Basic authentication. - **Basic**: standard HTTP Basic, only if enabled by an administrator. - A `?token=` query-string argument also works but is the least secure option and may be deprecated — avoid it. ## Request and Response Conventions - Commands that take data are `POST`s with a JSON body and `Content-Type: application/json`. - Response format is negotiated with `Accept` (`application/json`, `application/xml`, `text/plain`) or an `output_format` query argument (`json`, `xml`, `text`). **The default is XML.** - **Immediate vs eventual results**: listing commands return immediately; commands that initiate automation (pipelines, sync runs) return immediately *with an identifier*, and the client polls a corresponding `get_…` command for progress. Plan for polling in any automation client. ## What to Take Away If you are integrating with Agility itself, everything else on this site applies and Sync/Connect is irrelevant. Reach for Sync/Connect when the problem is *keeping Agility and another ALM tool aligned* — and then work from its own documentation rather than assuming Agility API conventions carry over. In particular: the `Token` (not `Bearer`) header and the XML default response have surprised many first requests. ## Related Topics - [Getting started with the Agility Sync API](https://docs.digital.ai/agility/docs/get-started/getting-started-with-agility-sync-api) — the product's public entry point - [Choosing an API](/getting-started/choosing-an-api) — where Sync/Connect fits among the surfaces --- # Query Grammar - URL: https://docs.digital.ai/agility-api/reference/query-grammar - Markdown: https://docs.digital.ai/agility-api/reference/query-grammar.md - Source: reference/query-grammar/index.md - Description: Normative reference for the attribute-path and filter syntax shared by rest-1.v1, query.v1 and api/asset: attribute paths, downcasts, aggregations, filter expressions, filter context, sort and paging. - Tags: reference, query-grammar, rest-1, query-v1, asset-bulk, filtering Agility has a compact, purpose-built syntax for choosing which attributes come back and for narrowing a result set. It is more expressive than it first looks: it supports attribute-path traversal across relations, downcasting to subtypes, aggregation rollups, filters applied part-way along a path, existence assertions, reusable variables and self-reference. Together these let a single expression walk the asset graph rather than requiring several round trips. **This syntax is not specific to one endpoint.** The same attribute paths and filter tokens turn up across the API: | Surface | Where the syntax appears | | --- | --- | | [`rest-1.v1`](/apis/rest-1) | `sel`, `where`, `sort` and the rest, on the query string | | [`query.v1`](/apis/query-v1) | `select`, `filter`, `sort` and the rest, inside a JSON or YAML body | | [`api/asset`](/apis/asset-bulk) | `select`, `where`, `filter` and `sort` on each command in a bulk request | | [`api/webhook`](/apis/webhooks/event-types) | `select`, `filter` and `with` on each event-type subscription | The fourth is easy to miss. A **webhook subscription is not a query**, but each event type it declares uses the same vocabulary to decide which assets it fires for and what to project into the delivered payload — so everything on these pages about attribute paths, filter expressions and filter context applies when you are writing a subscription. See [event types](/apis/webhooks/event-types). What differs between them is the **envelope** — query string versus request body — and the spelling of a few keywords. Webhooks take a subset: they filter and project, but there is nothing to sort or page. The vocabulary itself is common, so an attribute path such as `Scope.Workitems:Story[ToDo='0'].Estimate.@Sum` means the same thing wherever it appears. Learn it once. This section is the normative, human-readable description of that syntax. See [which surface uses what](#which-surface-uses-what) for the keyword differences. ## The Pages | Page | Covers | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | [Attribute paths](/reference/query-grammar/attribute-paths) | Dot notation, downcast `:Type`, aggregations `.@Sum`, now-only `#`, inline `[filter]` | | [Filter expressions](/reference/query-grammar/filter-expressions) | Comparison operators including `~=`, `and`/`or`/grouping, value lists, unary existence, variables | | [Filter context](/reference/query-grammar/filter-context) | The `with` parameter and variable assignment | | [Sort and paging](/reference/query-grammar/sort-and-paging) | `+`/`-` ordering, `page=size,start`, and reading the paging response attributes | | [Grammar reference](/reference/query-grammar/ebnf) | EBNF rendering of all three grammars, plus the raw `.g` files to download | ## Parameter Map Every parameter below is read from the query string of a `rest-1.v1` request. | Parameter | Purpose | Grammar | Notes | | ----------- | --------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `sel` | Attribute selection | [Attribute paths](/reference/query-grammar/attribute-paths) | `select` is an undocumented alias that **takes precedence** over `sel` | | `select` | Alias for `sel` | as `sel` | Not mentioned by the grammar at all | | `where` | Filter expression | [Filter expressions](/reference/query-grammar/filter-expressions) | **This is the filter parameter. There is no `filter=` on `rest-1.v1`.** | | `with` | Filter-context variable assignment | [Filter context](/reference/query-grammar/filter-context) | `$VAR=a,b\|$VAR2=c` — values are _not_ quoted here | | `sort` | Ordering | [Sort and paging](/reference/query-grammar/sort-and-paging) | `+` ascending (the default), `-` descending, comma-separated | | `page` | Paging | [Sort and paging](/reference/query-grammar/sort-and-paging) | `page=,` — **size comes first** | | `find` | Text search | none | Absent from the grammar entirely | | `findin` | Attributes that `find` searches | none | Absent from the grammar. Optional; defaults to Name + ShortName | | `asof` | History moment | none | Absent from the grammar. Accepts an **integer moment** _or_ a datetime | | `deleted` | Include deleted assets | none | Boolean flag. Absent from the grammar files. Defaults to off on **every** instance | | `needTotal` | Compute the `total` attribute on the response | none | Boolean flag. Without it a **paged** response omits `total` entirely (unpaged responses always carry a real one). Its default is **per-instance** | The two flags look alike but their defaults do not behave alike, which is worth knowing before you rely on either: | Flag | Default when omitted | Varies by instance? | | ----------- | ---------------------------------------------------------- | ------------------- | | `deleted` | Off — hardcoded | No | | `needTotal` | falls back to the `ApiDefaultNeedTotal` server setting | **Yes** | So `deleted` behaves identically everywhere, and `needTotal` does not. :::warning A paged response may have no `total` at all The default for `needTotal` comes from the instance-configurable `ApiDefaultNeedTotal` setting, so a client that pages off `total` can work against one instance and break against another. Send `needTotal=true` explicitly, and treat a missing `total` as "not computed", not as zero. See [Sort and paging](/reference/query-grammar/sort-and-paging#reading-the-response). ::: ### `asof` Accepts Two Different Things `asof` accepts either an **integer moment number** or a **datetime**. The datetime is parsed with invariant culture, so use ISO 8601 rather than a localized format. Both of these are valid, and they mean different things: ```text ?asof=113 # moment number 113 ?asof=2005-08-03T17:30:00.00 # the state as of that instant ``` A datetime `asof` returns the last historical record **on or before** the instant given. A value that parses as neither raises `400 Bad Request`. ## Which Parameters Apply to Which Request Shape This is not stated by the grammar, and it is a common source of "my filter was ignored" reports. The three request shapes build different queries: | Request shape | `sel` | `where` | `with` | `sort` | `page` | `find` | `asof` | `needTotal` | | -------------------------------------- | ----- | ------- | ------ | ------ | ------ | ------ | ------ | ----------- | | `/Data/{Type}` (a list) | yes | yes | yes | yes | yes | yes | yes | yes | | `/Data/{Type}/{id}` (a single asset) | yes | **no** | yes | **no** | **no** | **no** | yes | yes | | `/Hist/{Type}[/{id}]` (history) | yes | yes | yes | yes | yes | yes | yes | yes | :::danger On a single-asset URL, some parameters work and others are silently dropped Read the middle row carefully. `/Data/Story/1364` is **not** a URL where "the list parameters don't apply" — it is a URL where `deleted`, `needTotal`, `asof`, `sel` and `with` all take effect, while `where`, `sort`, `page` and `find` are discarded without comment. Same URL, same query string, two different fates, no error either way. The single-asset query is built from the OID with only the deleted flag, the total flag and a moment slice. No filter is ever attached, and nothing anywhere reports that one was supplied and ignored. `?where=` on this URL is not an error, not a warning, and not applied — it is simply as though you never sent it. ::: Two further consequences worth knowing: - The grammar agrees about filtering — "only valid when asking for a list of assets" — but says nothing about the parameters that _do_ survive. - On a history request with no `sort`, the server adds an implicit ascending sort on `Moment`. Supplying your own `sort` replaces it, so a history response is not guaranteed chronological once you sort by something else. If the URL addresses a single attribute — `/Data/Member/20/Name` — the selection is that attribute and `sel` is not consulted at all. :::danger The `#` operator must be percent-encoded The now-only operator in an attribute path is `#`, and it **must** be sent as `%23`. Unencoded, it is the URI fragment delimiter: your client truncates the URL there and never puts the rest on the wire, so the server answers a query you did not write. Depending on where the truncation lands you get either a `200` with a silently wrong result set or a `400`. The fault is client-side, so debugging the server will not explain it. See [Attribute paths](/reference/query-grammar/attribute-paths#now-only). ::: ## Selection Defaults, and Why You Get Attributes You Did Not Ask For Two selection defaults explain most surprises about response size. **Omitting `sel` does not return everything.** It returns `assettype.GetBasicAttributeDefinitions()` — a per-asset-type basic set. Never assume an unfiltered request is exhaustive; if you need a specific attribute, name it. **Selecting a relation implicitly selects more.** After your selection is parsed, every attribute whose type is `Relation` additionally selects, on the related asset: - its designated **name** attribute; - its designated **short-name** attribute, where the type declares one — and the designation names a real attribute, so it surfaces as `Owners.Nickname` or `Workitems.Number`, never as a literal `ShortName`; - the relation's **DisplayBy** attribute, where the relation declares one. Each of the three is conditional and they frequently coincide — `DisplayBy` and the short-name designation are usually the name attribute again — so the common case is exactly one extra attribute: `?sel=Status` really returns `Status` plus `Status.Name`. This is why responses contain attributes you never requested, and it is the first thing to look at when tuning payload size — the expansion is per relation attribute selected, so a selection with many relations grows faster than it looks. Combined with the absence of a default page size, these two defaults are the main reason Agility integrations pull more data than intended. See [Query size and performance](/guides/query-size-and-performance). ## Grammar Versus Implementation The published grammar files were last revised in 2012 and describe the intended syntax rather than the shipped implementation. **Where the grammar and the server disagree, the server wins.** These are the known disagreements: | # | The grammar says… | The server actually… | | - | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | 1 | The filter root rule is `filter2_token` | Reads the query parameter **`where`**. `Filter2` is an internal type name that never appears on the wire; there is **no `filter=` parameter** on `rest-1.v1` | | 2 | Selection comes from `sel` | Also accepts `select`, an alias that **wins** when both are present | | 3 | Nothing about `find`, `findin`, `asof`, `deleted` or `needTotal` | Supports all five | | 4 | Selection is exactly what you asked for | Expands every relation selection with up to three conditional attributes — commonly just Name | | 5 | Nothing about the default selection | Falls back to a per-type basic set, not to all attributes | | 6 | `paging_token : page_size (',' page_start)?` | Agrees — size first, then start | | 7 | `sort_token` has no default | Adds an implicit ascending `Moment` sort on history requests when `sort` is absent | | 8 | Filtering, sorting and paging are properties of a query | Are not applied at all on single-asset `/Data/{Type}/{id}` requests, while `deleted` and `needTotal` are | Item 6 is listed because it is frequently gotten backwards, not because it is a disagreement — all three sources agree that size comes first. ## Which Surface Uses What \{#which-surface-uses-what\} These three surfaces **share the attribute-path and filter-expression vocabulary** and differ in envelope and keyword spelling. Webhook subscriptions are left out of the table because they are not a query surface — they support `select`, `filter` and `with` but have no `from`-and-return semantics, no sort and no paging. See [event types](/apis/webhooks/event-types) for the fields they do accept. | Aspect | `rest-1.v1` | `query.v1` | `api/asset` | | --------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------ | --- | | Envelope | GET, parameters on the query string | POST, a YAML document in the body (JSON is valid YAML) | POST, a JSON command or array of commands | | Root asset type | The URL path — `/Data/Story` | `from: Story` (an `as` alias parses but is silently discarded) | `from` — an asset type or an OID token | | Selection | `sel` or `select`, one comma-separated token | `select` or `sel`, a list; nested mappings are subqueries (an entry's `as` alias parses but is silently discarded) | `select`, a list | | Filter expression | `where=` | **`filter:`** — a scalar or list of expressions in the same language | **`filter`** — an array of expressions, as on `query.v1` | | Equality shorthand | not available | **`where:`** — a mapping of attribute to a single scalar value | **`where`** — an object, as on `query.v1` | | Filter context / variables | `with=$V=a,b\|$W=c` | `with:` — a mapping of name to a scalar or a list | uses [aliases](/apis/asset-bulk/aliases-and-projection) instead | | Sort | `sort=-Estimate,+Name` | `sort: ['-Estimate', '+Name']` | `sort`, a list | | Paging | `page=,` (positional) | `page: { size: 50, start: 0 }` (named; **both keys required**) | `page: { start, size }` (named; `start` defaults to 0) | | Text search | `find`, `findin` | `find`, `findin` | `find` is command-dependent — see below | | History | `asof`, plus the separate `/Hist` namespace | `asof` | not applicable | | Deleted assets | `deleted=true` | `deleted: true` | `IsDeleted` per command — the `?deleted=1` URL flag does not surface deleted rows on its own | | Total count | `needTotal=true` | `needTotal: true` | `needTotal: true` | | Grouping | not available | `group:` — a list of expressions, and grouping subqueries | not available | | Several queries per request | no | yes — the document root may be a sequence of query mappings | yes — the body may be an array of commands | | Writes | `op` for operations; POST a payload to update | not available — `update` and `execute` are stripped | `update` and `execute` on any command | :::warning `find` does not always mean text search On `rest-1.v1` and `query.v1`, `find` searches text attributes and pairs with `findin`. On `api/asset` its meaning depends on the command it appears in. In an **update** command, `find` is half of the [find-and-replace](/apis/asset-bulk/find-and-replace) form — it names the substring to be replaced and pairs with `replace`, not with `findin`. In an `api/asset` **query** command, `find` is a text search, as on `query.v1`: it pairs with `findin`. The practical caution: a query carrying `find` that is ported into a Bulk API update command will be read as a replacement instruction, not a search. ::: :::warning `where` does not mean the same thing in both APIs On `rest-1.v1`, `where` is the full filter language. On `query.v1`, `where` is a plain equality mapping and the full filter language lives under `filter`. So `?where=ToDo='0'` and `filter: ["ToDo='0'"]` are equivalent — but `where: { ToDo: '0' }` on `query.v1` is the shorthand, not the expression. Conflating the two is the single most common mistake when porting a query from one surface to the other. ::: Two further `query.v1` details worth knowing: - The keys `update` and `execute` are stripped from the document before validation, so including them is neither honoured nor rejected. - Any other unrecognised key is rejected with `Invalid key at node(s): …` when key validation is enabled. ## Errors A malformed value for any of these parameters produces `400 Bad Request` with an `Error` payload whose `Message` names the parameter — for example `Invalid SEL parameter`, with a nested `Exception` element carrying the detail such as `Unknown AttributeDefinition: Story.Bogus`. The full list is in [HTTP status codes](/reference/status-codes#query-parameter-errors). ## Machine-Readable Grammar The three ANTLR grammar files are published verbatim, license headers intact, at stable URLs — fetch them if you are building a parser, linter or query builder: - [`V1Query.g`](pathname:///grammars/V1Query.g) — selection, attribute paths, filter expressions, sort - [`V1FilterContext.g`](pathname:///grammars/V1FilterContext.g) — the `with` parameter - [`V1Paging.g`](pathname:///grammars/V1Paging.g) — the `page` parameter They are transcribed as EBNF on the [grammar reference](/reference/query-grammar/ebnf) page. Read them as a syntax map, not as an oracle — see the divergences above. ## Related Topics - [REST API (`rest-1.v1`)](/apis/rest-1) - [Query API (`query.v1`)](/apis/query-v1) - [Assets and attributes](/concepts/attributes) --- # Attribute Paths - URL: https://docs.digital.ai/agility-api/reference/query-grammar/attribute-paths - Markdown: https://docs.digital.ai/agility-api/reference/query-grammar/attribute-paths.md - Source: reference/query-grammar/attribute-paths.md - Description: Dot notation, downcasting to a subtype, aggregation rollups, the now-only operator, and filters applied part-way along an attribute path. - Tags: reference, query-grammar, attributes, relations, aggregation An **attribute path** names a value relative to an asset. It is the single most reused construct in the query syntax: the same production appears in `sel`, in `sort`, on the left-hand side of a term in `where`, and as the last segment of a `rest-1.v1` URL. ```text attribute_name = attribute_name_part , { "." , attribute_name_part } , [ ".@" , aggregation_name ] ; attribute_name_part = NAME , [ "#" ] , [ ":" , asset_type ] , [ "[" , filter_expression , "]" ] ; ``` Read that second line carefully — the four pieces of a path part are ordered. The now-only marker comes before the downcast, and the downcast comes before the inline filter. ## Dot Notation Attributes are either **scalars** or **relations** to other assets, and relations are either single-value or multi-value. When an attribute is a relation, `.` continues the path onto the related asset. ```text Name # a scalar on this asset Category.Name # the Name of the related Category Scope.Name # the Name of the owning project Workitems.Name # every Name across a multi-value relation ``` A path may be used as the final segment of a URL as well as inside `sel`: ```text GET /rest-1.v1/Data/Story/1055/Category.Name GET /rest-1.v1/Data/Scope/0/Workitems.Name ``` There is no depth limit in the grammar. Each traversal is a join on the server, so long paths are correct but not free; see the note on cost at the end of this page. :::info Selecting a relation selects more than you asked for `?sel=Status` also returns `Status.Name`, because the server expands every relation in your selection with up to three conditional attributes — the related type's designated name and short-name attributes and the relation's `DisplayBy`, which usually coincide. This is [documented in the overview](/reference/query-grammar#selection-defaults-and-why-you-get-attributes-you-did-not-ask-for). ::: ## Downcast Agility's asset types form a hierarchy: `Workitem` is abstract, and `Story`, `Defect`, `Task` and `Test` are among its concrete subtypes. **An abstract type only exposes the attributes defined at its own level.** `Workitem` has no `Category` attribute; `Story` does. The downcast operator `:` narrows a path part to a subtype so the subtype's attributes become reachable: ```text Workitems:Story.Category ``` ```text GET /rest-1.v1/Data/Scope/0/Workitems:Story.Category ``` :::warning Without the downcast, the request fails Reaching for a subtype attribute through an abstract relation is not a silent no-op — it is an error. `?sel=Workitems.Category` returns `400 Bad Request` with `Invalid SEL parameter` and an inner `Unknown AttributeDefinition` message. This is the top-tier gotcha when traversing `Workitem`, `BaseAsset` or `Scope` relations, because those relations are typed at the abstract level far more often than people expect. ::: The downcast also restricts: `Workitems:Story` addresses the `Story` members of the relation, so `Workitems:Story.Category` yields one value per Story and nothing for the Defects and Tasks in the same relation. (Inferred from the purpose of the operator as described in `V1Query.g`; the grammar does not state the set semantics explicitly and no shipped example demonstrates it, so treat the restriction as unverified.) ## Aggregations An aggregation rolls a set of values up to a single value. It is written `.@` followed by the aggregation name, and it may only appear once, at the very end of a path. | Aggregation | Meaning | | --------------- | --------------------------------------------------------- | | `Sum` | Sum the returned values | | `Any` | True if the set is non-empty (a count-greater-than-zero test on a relation) | | `Count` | Count the returned assets | | `DistinctCount` | Count the returned assets, counting each asset only once | | `MinDate` | The oldest date among the returned values | | `MaxDate` | The newest date among the returned values | | `And` | True if all the returned values are true | | `Or` | True if any of the returned values is true | | `MaxState` | The highest state value in the set | ```text GET /rest-1.v1/Data/Scope/0/Workitems.@Count GET /rest-1.v1/Data/Scope/0/Workitems:PrimaryWorkitem.Estimate.@Sum ``` :::warning Aggregating through an abstract relation needs a downcast `Workitems.Estimate.@Sum` is a `400` — `Unknown AttributeDefinition: Workitem.Estimate`. The `Workitems` relation targets the abstract `Workitem` type, and `Estimate` is defined on its subtypes (`PrimaryWorkitem`, `Task`, `Test`), not on `Workitem` itself. [Downcast](#downcast) to the subtype whose attribute you are rolling up, as the second example does. ::: Note what the two forms aggregate. `Workitems.@Count` counts assets; `Workitems:PrimaryWorkitem.Estimate.@Sum` sums a scalar reached through the relation. `Count` and `DistinctCount` differ only when the path can arrive at the same asset by more than one route. `Any` tests a relation for emptiness — unlike `And` and `Or`, which aggregate boolean values reached through the path: ```text GET /rest-1.v1/Data/Scope/0?sel=Workitems.@Any ``` Aggregations compose with everything else on this page, and the aggregation always goes last: ```text Workitems:Story[AssetState!='Closed'].Estimate.@Sum ``` :::note `AssetState!='Closed'` is narrower than it reads It compiles to `< 128`, so it excludes **Dead** and **Deleted** assets as well as Closed ones — it means "before Closed", not "not Closed". See [State attributes](/reference/query-grammar/filter-expressions#state-attributes). ::: ## Now-Only {#now-only} In a history request, every referenced asset is resolved *as it was at that moment*. The now-only operator `#` overrides that for one path part, so the value is taken from the asset's **current** state instead. ```text Parent#.Name ``` The canonical use is "find all the stories that belong to the project *currently* named Website", which is not the same question as "find all the stories that belonged to a project named Website at the time": ```text GET /rest-1.v1/Hist/Story?where=Parent%23.Name='Website' ``` :::danger `#` must be sent as `%23` — the failure happens in your client, not on the server `#` is the URI fragment delimiter ([RFC 3986 §3.5](https://www.rfc-editor.org/rfc/rfc3986#section-3.5)). A compliant HTTP client splits the URL there and **transmits neither the `#` nor anything after it**. The server never sees a `#` at all — it sees a **truncated query string** and answers whatever that truncated query happens to mean. This matters when you debug it: nothing is wrong on the server, so no amount of server-side investigation will explain the result. The request the server received is not the request you wrote. **What you observe depends on where the truncation lands:** | Truncation leaves… | Result | | --- | --- | | A still-valid query string | `200` with a silently wrong result set | | A half-finished token | `400` with an `Invalid …` error | So `?where=Parent#.Name='Website'` is transmitted as `?where=Parent` — a [bare attribute name](/reference/query-grammar/filter-expressions#bare-attribute-names), which is syntactically legal — and returns `200` full of the wrong assets. Move the `#` one character later, into the middle of a token, and the same mistake becomes a `400` instead. Neither outcome tells you the truth, and which one you get is an accident of string position. Percent-encode it: `?where=Parent%23.Name='Website'`. Most HTTP client libraries will **not** do this for you when you build the URL as a string, because `#` is legal in a URL. Encode it yourself, or pass the query string through a parameter-encoding API. ::: The grammar states the same warning in the comment above `now_only`: "the encoding of `#` (as `%23`) is not optional… using `#` instead of the encoded value may return a confusing result." ## Inline Filters A filter in square brackets may be attached to any path part, not just the last one. It reduces the set of assets traversed at that point, before the rest of the path is evaluated. ```text GET /rest-1.v1/Data/Scope/0/Workitems[ToDo='0'] ``` The contents of the brackets are a full [filter expression](/reference/query-grammar/filter-expressions) — the same language as `where`, including boolean operators, grouping, value lists, unary existence tests and variables. Inline filters may be nested inside one another to arbitrary depth, because a filter expression can itself contain attribute paths that carry filters. Position matters. These two are different questions: ```text Workitems:PrimaryWorkitem[ToDo='0'].Estimate.@Sum # sum Estimate over the primary workitems with no ToDo Workitems:PrimaryWorkitem.Estimate.@Sum # sum Estimate over all primary workitems ``` (The downcast is required here for the reason the [aggregation warning](#aggregations) gives: `Estimate` is not defined on the abstract `Workitem`.) ## Ordering of the Operators All four modifiers can appear on the same path part, and the grammar fixes their order: name, then `#`, then `:Type`, then `[filter]`. ```text Workitems#:Story[AssetState!='Closed'].Category.Name ``` Writing them in any other order is a syntax error. ## Worked Example: the Capstone Query `V1Query.g` carries this example against its `variable` rule. It is worth walking through because it uses downcast, inline filters, nested inline filters and self-reference at once. ```text GET /rest-1.v1/Data/Scope/0 ?sel=ChildrenMeAndDown[AssetState!='Closed'] .Workitems:Epic[AssetState!='Closed';-SuperAndUp[Scope.ParentMeAndUp[AssetState!='Closed']=$]] ``` (Wrapped for readability; on the wire it is one unbroken value.) Reading it outside-in: 1. **`/Data/Scope/0`** — start at the root project. 2. **`ChildrenMeAndDown[AssetState!='Closed']`** — walk to that project and every project beneath it, keeping only the ones that are not closed. `ChildrenMeAndDown` is one of Agility's transitive-closure relations; the `MeAndDown` suffix indicates it includes the asset itself. (Relation semantics are metadata, not grammar — confirm the exact membership of any `…MeAndUp` / `…MeAndDown` relation through [meta.v1](/apis/meta-v1).) 3. **`.Workitems:Epic[…]`** — from each of those projects, take the workitems and downcast to `Epic`, so non-Epic workitems drop out and Epic attributes become available. 4. **`AssetState!='Closed'`** — keep only open Epics. 5. **`;`** — AND. See [filter expressions](/reference/query-grammar/filter-expressions#boolean-operators); `;` and `&` both mean AND. 6. **`-SuperAndUp[…]`** — the unary `-` asserts that the bracketed set is **empty**. `SuperAndUp` walks from an Epic up through its parent Epics, so this reads "has no ancestor Epic matching the inner filter". 7. **`Scope.ParentMeAndUp[AssetState!='Closed']=$`** — the inner filter. For each ancestor Epic, take its project, walk that project and its ancestors, keep the open ones, and compare to `$`. 8. **`$`** — the context asset, meaning "self". Here that is the Scope the selection is being evaluated against. Net result: **open Epics in open projects at or below the root project, that are not contained by another open Epic in a still-open project.** In other words the top-level Epics of the open portfolio — a query that would be tedious to express as several round trips and impossible to express as a flat filter. ## A Note on Cost Every dot is a traversal and every bracket is a sub-select. The syntax makes it easy to write a single URL that is expensive to answer. Two practical habits: - Prefer aggregations to fetching a list and summing it client-side — the rollup happens server-side and the payload is one value. - Name the attributes you need in `sel`. Omitting `sel` gets you the basic set plus relation expansion, which is usually more data than you wanted; see the [overview](/reference/query-grammar#selection-defaults-and-why-you-get-attributes-you-did-not-ask-for). ## Related Topics - [Filter expressions](/reference/query-grammar/filter-expressions) - [Filter context](/reference/query-grammar/filter-context) - [Grammar reference](/reference/query-grammar/ebnf) - [Attributes](/concepts/attributes) and [Relations](/concepts/relations) --- # Filter Expressions - URL: https://docs.digital.ai/agility-api/reference/query-grammar/filter-expressions - Markdown: https://docs.digital.ai/agility-api/reference/query-grammar/filter-expressions.md - Source: reference/query-grammar/filter-expressions.md - Description: The where language: comparison operators including LIKE, boolean composition and grouping, value lists, unary existence assertions, and variables. - Tags: reference, query-grammar, filtering, where A **filter expression** narrows a result set. The same language is used in two places: - the `where` query parameter, which filters the assets a list request returns; - inside `[ … ]` on an [attribute path](/reference/query-grammar/attribute-paths#inline-filters), which reduces the set traversed at that point in the path. ```text filter_expression = term , { ( "&" | ";" | "|" ) , term } ; term = "(" , filter_expression , ")" | attribute_name , [ binary_operator , ( value_list | variable ) ] | ( "+" | "-" ) , attribute_name ; binary_operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~=" ; value_list = value , { "," , value } ; value = "'" , ? any character except ' ? , "'" | '"' , ? any character except " ? , '"' ; variable = "$" | "$" , NAME ; ``` :::warning There is no `filter` query parameter on `rest-1.v1` The grammar names the root rule for this language `filter2_token`, which has led readers to look for `filter=`. The wire parameter is **`where`** — `Filter2` is an internal name that never appears on the wire. `query.v1` — the JSON/YAML API — is different: it has **both** a `filter` key (this language) and a `where` key (a plain equality mapping). See the [comparison table](/reference/query-grammar#which-surface-uses-what). ::: ## The Simplest Case ```text GET /rest-1.v1/Data/Story?sel=Name,ToDo&where=ToDo='0' ``` Note the quotes around `0`. The shipped example calls this out explicitly: "The number 0 in the where query parameter is surrounded by single-quote marks." Values are always quoted, whatever their underlying type — the grammar has no unquoted numeric or boolean literal. ## Comparison Operators | Operator | Meaning | | -------- | -------------------------- | | `=` | Equal to | | `!=` | Not equal to | | `<` | Less than | | `<=` | Less than or equal to | | `>` | Greater than | | `>=` | Greater than or equal to | | `~=` | Full-text search — **not** a substring match, see below | `~=` is the least-known of the seven, absent from every other description of this syntax, and the most likely to surprise you: ```text ?where=Name~='Ticket' ``` :::danger `~=` is full-text search, not `LIKE` It compiles to a SQL Server **full-text** predicate — literally `Contains(, )` — against the full-text index, not to a `LIKE '%…%'` scan. The consequences are not subtle: - **It matches whole words, not substrings.** `Name~='Cog'` does **not** match a Story called `Cogent Refactor`. A substring that is not a complete word will not be found. - **Searching an exact title often returns nothing.** This is the most common complaint about it. A title made up of short or common words can be reduced to nothing usable once the index's stopword list and word breakers have been applied, so the asset you are looking at on screen does not come back. - **It depends on the full-text index.** If the index is missing, disabled or has not caught up with recent writes, results are incomplete rather than wrong-but- explainable. A newly created asset may not be findable immediately. - **The term you send is rewritten** into a valid full-text condition before it is executed. What runs is not always character-for-character what you supplied. - **Through a multi-value relation, spell it as a sub-filter.** `MentionedInExpressions[Content~='release']` matches; `MentionedInExpressions.Content~='release'` returns nothing, silently, no matter how long the index has had. The dotted spelling through a multi-value relation is compiled as an exact `=` comparison on the joined value, so a full-text term never matches a whole comment. (Exact `=` works in both spellings; single-value relations such as `Owner.Name~='jim'` are fine.) The sub-filter spelling is also the only way to negate a full-text term, since there is no `!~=`: `-MentionedInExpressions[Content~='release']` is "no mentioning comment contains release". ::: ### The Value Is a Search Expression, Not a Literal This is the part that catches people, and it follows from `~=` being full-text: **what you put on the right-hand side is parsed as a full-text search condition**, not treated as the text to look for. So `AND`, `OR`, `NOT`, `NEAR`, parentheses, double quotes and asterisks are all **operators**. A title that happens to contain any of them is not searched for literally — it is interpreted. Before execution the value passes through a normalizer, and its defaults are the source of most surprises: | Behaviour | Effect | | --- | --- | | **Inflectional stemming** | Terms are wrapped in `FORMSOF(INFLECTIONAL)`, so `'running'` also matches *run* and *ran*. Matching is broader than the literal you typed. | | **Phrase stemming is per-instance** | Controlled by an instance configuration setting, so identical queries can return different results on two instances. | | **`*` is a prefix wildcard only** | `'Cog*'` matches words starting with *Cog*. An **inner** asterisk is trimmed at the first one — `'Cog*ent'` becomes `'Cog*'`, a silently **broader** result. A **leading** asterisk is dropped to nothing, so `'*Cog'` matches **nothing** — there is no suffix or infix wildcard. | | **Unbalanced parentheses are patched** | Not rejected — quietly repaired into something valid. | | **Unbalanced quotes are closed for you** | The condition is closed at the end, and a lone inner quote is assumed to be intentional. | | **Invalid `NEAR` becomes `AND`** | A malformed proximity search silently degrades into a conjunction. | **Nothing in that list produces an error.** Every malformed or unexpected input is repaired into a valid condition and executed, so a `~=` filter always "works" — it just may not be the search you wrote. ### Stop Words Are the Other Half of the Problem `~=` runs against the same SQL Server full-text index the product's own search uses, so it inherits that index's **stop word list** — common words and **single characters** that are never indexed. A search term made only of stop words matches nothing, and there is no error to tell you why. The effect is easiest to see in these real examples, which come from the product documentation and apply equally to `~=`: | Term | Why it returns nothing | | --- | --- | | `Iteration 1` | `1` is a single-character term | | `Book A` | `A` is a single-character term | | `Page 18 of 42` | `of` is a stop word | | `1.2.3 ABC` | three single-character terms — treated as `1 AND 2 AND 3 AND ABC` | **This is the concrete reason an exact title often fails.** Titles are full of short tokens, version numbers and connecting words, and once those are removed there may be nothing left to match on. On hosted instances the SQL Server default list applies and cannot be changed. On on-premise installations the list is the customer's to alter. The full list, and the search behaviour it drives, are published in the product documentation: [Search stop words](https://docs.digital.ai/agility/docs/agility/search-stop-words-list) and [Search Agility](https://docs.digital.ai/agility/docs/agility/searching-digital). :::tip Practical advice If you are searching for something a user typed, and you want it found rather than interpreted, **use `find` instead** — see below. Reserve `~=` for cases where you actually want word-level search over indexed text and understand the normalization above. ::: ### If You Want a Substring Match, Use `find` `find` and `findin` are the substring tool. `find` compiles to a genuine `LIKE '%value%'`, optionally case-insensitive, so it matches partial words the way most people expect `~=` to: | | `~=` | `find` / `findin` | | --- | --- | --- | | Compiles to | `Contains(col, term)` — full-text | `col LIKE '%value%'` | | Matches | whole words | any substring | | `'Cog'` finds `Cogent Refactor` | no | **yes** | | Needs a full-text index | **yes** | no | | Scope | one named attribute | a set of attributes, defaulting to Name and ShortName | | Cost | index lookup | table scan — slower on large sets | So the two are not variations on a theme. Choose `~=` when you want word-level matching over indexed text and can live with stopwords; choose `find` when you want "does this string appear anywhere in the value". ## State Attributes Do Not Compare Like Other Values \{#state-attributes\} `AssetState` is a `State`-typed attribute, and comparisons against a **named** state do not behave like comparisons against an ordinary value. This is worth understanding before writing any filter on `AssetState`, because the surprising case is a common one. States are stored as bytes and each named state is a **range**, not a point: | State | Byte | Range the name covers | | --- | ---: | --- | | `Future` | 0 | below 64 | | `Active` | 64 | 64 to 127 | | `Closed` | 128 | 128 to 191 | | `Dead` | 192 | 192 to 254 | | `Deleted` | 255 | exactly 255 | So `AssetState='Closed'` does not compile to `= 128`. It compiles to `>= 128 AND < 192` — the whole Closed band. That part is intuitive enough. :::danger `!=` on a state is not a negation `AssetState!='Closed'` compiles to **`< 128`**, not to "anything outside the Closed band". That is narrower than it reads. It excludes Closed — and it also excludes **Dead** and **Deleted**, which are not Closed. The filter means *"before Closed"*, not *"not Closed"*. | Filter | Compiles to | Reads as | Actually means | | --- | --- | --- | --- | | `AssetState!='Future'` | `>= 64` | not Future | not Future ✓ | | `AssetState!='Active'` | `< 64 OR >= 128` | not Active | not Active ✓ | | `AssetState!='Closed'` | `< 128` | not Closed | **before Closed** — also excludes Dead and Deleted | | `AssetState!='Dead'` | `< 192` | not Dead | **before Dead** — also excludes Deleted | | `AssetState!='Deleted'` | `< 255` | not Deleted | not Deleted ✓ | Note that `!='Active'` *is* a true negation while `!='Closed'` is not, so you cannot infer the behaviour of one case from another. In practice `!='Closed'` is usually what people want, because they mean "still open" and Dead assets are not interesting. The trap is when you genuinely need "everything except Closed" — for that, say so explicitly with an [OR of the states you want](#boolean-operators), rather than relying on `!=`. ::: :::note Named and numeric forms behave alike A number that matches a named state takes the named state's range: `AssetState!='128'` is `< 128`, exactly like `AssetState!='Closed'`. Any other number, such as `200` for a template, is compared as a plain byte. ::: ## Boolean Operators {#boolean-operators} | Operator | Meaning | | ----------- | -------- | | `&` and `;` | AND | | `\|` | OR | | `( )` | Grouping | **`&` and `;` are exact synonyms.** Both parse to AND. `;` exists because `&` separates parameters in a query string, so an unescaped `&` inside a `where` value ends the parameter. Prefer `;` in hand-written URLs — it needs no escaping and reads unambiguously: ```text ?where=AssetState!='Closed';Estimate>'3' ``` If you build URLs programmatically with proper parameter encoding, `&` inside the value is encoded as `%26` and either form works. The grammar gives AND and OR the **same precedence** and left-associates them (`filter_expression` is a flat repetition, not a precedence cascade). Do not rely on `&` binding tighter than `|` the way it would in C or SQL. Parenthesise whenever you mix them: ```text ?where=(Status.Name='Ready'|Status.Name='Development');AssetState!='Dead' ``` ## Value Lists A comparison may take a comma-separated list of values, which acts as an implicit in-list: ```text ?where=Status.Name='Done','Accepted' ``` Single and double quotes are both accepted, and they are interchangeable — use double quotes when the value contains an apostrophe: ```text ?where=Name="Bob's story" ``` The grammar defines a quoted string as an opening quote, any run of characters that are not that quote character, and a closing quote. **There is no escape sequence.** A value cannot contain the quote character that delimits it, and there is no way to write a value containing both `'` and `"`. That is a genuine expressive limit, not an omission from this page. Combined with a negated operator, a value list means "none of these": ```text ?where=AssetState!='Closed','Dead' ``` ### The `IsDeleted` OR-Form Hack {#isdeleted-hack} One value-list idiom is worth calling out because it changes which assets the server returns rather than just narrowing them: ```text ?where=IsDeleted='True','False' ``` Ordinarily the server excludes deleted assets automatically. Asserting both values defeats that exclusion, so deleted assets come back too. Core's own comment calls this "the OG `IsDeleted` filter hack" and names **`query.v1` and `api/asset` integrations** as its users, so you will encounter it in existing integration code even though it is not the documented mechanism. Two things to know before you rely on it: - **`IsDeleted` selects back as the string `"True"`, not a boolean.** Compare it as a string, and do not expect a JSON `true` when you select it. - The supported way to include deleted assets is the [`deleted` parameter](/reference/query-grammar#parameter-map). Prefer it in new code; recognise the hack when reading old code. ## Unary Existence A `+` or `-` prefixed to an attribute path asserts that the attribute has, or does not have, a value: | Form | Meaning | | ------- | ---------------------------------------------- | | `+Attr` | The attribute is present / the set is non-empty | | `-Attr` | The attribute is absent / the set is empty | ```text GET /rest-1.v1/Data/Scope/0?sel=Workitems[-Owners;ToDo>'0'] ``` That reads "workitems in the root project that have no owner and still have work remaining". This is the only way to express a null test — there is no `=''` null literal. `-Owners` on a multi-value relation means the relation is empty; `-Description` on a scalar means it has no value. Do not confuse the unary operators with the sort direction operators, which use the same two characters in a different parameter. `sort=-Estimate` is descending order; `where=-Estimate` is "has no estimate". ## Bare Attribute Names The grammar permits a term that is just an attribute path with no operator at all — `attribute_name , [ binary_operator … ]` makes the comparison optional. It is not documented what a bare term evaluates to, and no shipped example uses one. Treat it as unspecified and write `+Attr` when you mean an existence test. It matters for one reason: it is why the unencoded-`#` mistake can produce a successful response rather than an error. An unencoded `#` is a URI fragment delimiter, so the client truncates the URL there and never transmits the rest — `?where=Parent#.Name='Website'` arrives as `?where=Parent`, which parses as a bare term and does not fail. Truncate one character later and you get a `400` instead. See the [danger note on attribute paths](/reference/query-grammar/attribute-paths#now-only). ## Variables A filter value may be a variable instead of a literal list: | Form | Meaning | | -------- | -------------------------------------------------------------- | | `$NAME` | The value assigned to `NAME` by the `with` parameter | | `$` | The **context asset** — "self" | Variables save repeating a long list, and they are the only way to write a self-referential comparison. Assignment is covered on [Filter context](/reference/query-grammar/filter-context): ```text GET /rest-1.v1/Data/Epic ?where=AssetState!='Closed';Scope=$Scope;-SuperAndUp[Scope=$Scope] &with=$Scope=Scope:0,Scope:1,Scope:2 ``` `$` alone refers to the asset the expression is being evaluated against, which lets a nested inline filter compare an inner traversal back to the outer subject. The [capstone example](/reference/query-grammar/attribute-paths#worked-example-the-capstone-query) on the attribute-paths page is built around this. ## Attribute Paths Inside Filters The left-hand side of a term is a full attribute path, so everything on the [attribute paths](/reference/query-grammar/attribute-paths) page is available here too — dot traversal, downcast, now-only and nested inline filters: ```text ?where=Workitems:Story[AssetState!='Closed'].Category.Name='UI' ?where=Parent%23.Name='Website' ``` Aggregations are grammatically legal on the left-hand side of a comparison as well, since `attribute_name` includes the optional `.@` suffix. No shipped example uses one there, so treat `?where=Workitems.@Count>'5'` as unverified. ## Errors A `where` value that does not parse, or that names an attribute that does not exist on the asset type, produces `400 Bad Request` with the message `Invalid WHERE parameter` and a nested `Exception` element giving the detail. See [HTTP status codes](/reference/status-codes#query-parameter-errors). Note that `where` is only honoured on list requests and on history requests. On a single-asset request such as `/Data/Story/1055` the parameter is ignored without complaint — see [which parameters apply to which request shape](/reference/query-grammar#which-parameters-apply-to-which-request-shape). ## Related Topics - [Attribute paths](/reference/query-grammar/attribute-paths) - [Filter context](/reference/query-grammar/filter-context) - [Sort and paging](/reference/query-grammar/sort-and-paging) - [Grammar reference](/reference/query-grammar/ebnf) --- # Filter Context - URL: https://docs.digital.ai/agility-api/reference/query-grammar/filter-context - Markdown: https://docs.digital.ai/agility-api/reference/query-grammar/filter-context.md - Source: reference/query-grammar/filter-context.md - Description: The with parameter: assigning variables that filter expressions reference, and how its lexing rules differ from the filter language. - Tags: reference, query-grammar, filtering, variables, with The `with` parameter assigns values to the variables a [filter expression](/reference/query-grammar/filter-expressions#variables) references with `$NAME`. Agility calls this the **filter context**. ```text filter_context_token = [ assignment , { "|" , assignment } ] ; assignment = "$" , NAME , "=" , value , { "," , value } ; value = ? any run of characters other than "," and "|" ? ; ``` ## Assignment Assignments are separated by `|`; the values within one assignment are separated by `,`: ```text &with=$Scope=Scope:0,Scope:1,Scope:2 &with=$Story=Story:3|$Scope=Scope:0,Scope:1,Scope:2 ``` A variable holding several values behaves as a value list wherever it is used, so `Scope=$Scope` against the assignment above means "Scope is any of `Scope:0`, `Scope:1`, `Scope:2`". ## Worked Example ```text GET /rest-1.v1/Data/Epic ?where=AssetState!='Closed';Scope=$Scope;-SuperAndUp[Scope=$Scope] &with=$Scope=Scope:0,Scope:1,Scope:2 ``` `$Scope` is referenced twice — once at the top level and once inside a nested inline filter — from a single assignment. The query reads "open Epics in any of these three projects that are not contained by another Epic in those same projects", i.e. the top-level Epics of that project set. That reuse is the point of the feature. Without it you would repeat the OID list in both places, and the two copies would drift. :::warning Values in `with` are not quoted — unlike values in `where` This is the difference that catches people. In `where`, every value is a quoted string: `where=ToDo='0'`. In `with`, values are bare: `with=$Scope=Scope:0`. The reason is in the grammar's own preamble: "The actual implementation of this grammar is done with simple token splitting." `V1FilterContext.g` has to model that with a lexer mode — `CONTEXT_VALUE` is defined as `~(',' | '|')*`, any run of characters that is not a comma or a pipe. Quote marks are not delimiters there; they are just more characters in the value. So `with=$Scope='Scope:0'` assigns the eight-character string `'Scope:0'`, apostrophes included, and the filter that compares against it silently matches nothing. ::: Two consequences of the same token-splitting rule: - A value may contain spaces, colons, quotes and equals signs. Only `,` and `|` terminate it. (URL-encoding rules still apply on top of this — encode the parameter value as usual.) - A value **cannot** contain a comma or a pipe, and there is no escape mechanism. If a value you need contains either character, `with` cannot carry it; inline the value in the filter expression instead, where quoting exists. ## What Can Be Assigned The shipped examples all assign OIDs (`Scope:0`, `Story:3`). The grammar itself places no constraint on the value beyond the delimiter rule, so plain scalars also parse. Whether a given variable is meaningful in a given comparison depends on the attribute it is compared against; that is not something the grammar constrains, and no shipped example demonstrates a non-OID assignment. Treat non-OID values as unverified. `$` on its own — the context asset — is **not** assignable through `with`. It is supplied by the query itself. `V1FilterContext.g` only produces `VARIABLE_NAME`, which requires a name after the `$`. ## Where It Applies Unlike `where`, filter context is applied to every query shape, including single-asset requests: `AddQueryFilterContext` is called from `DoAssetQuery`, `DoAssetTypeQuery` and `DoHistoricalAssetQuery` alike. That matters because a single-asset request can still carry a `sel` containing inline filters that reference variables. ## Reserved Variables Two variable names are supplied by the server rather than by your `with` parameter: - **`$` on its own is the asset itself.** In a filter that is evaluated against a concrete asset — a bracket filter on an [attribute path](/reference/query-grammar/attribute-paths), or the valid-values filters the schema defines — `$` resolves to that asset: ```text GET /rest-1.v1/Data/Scope/1067?sel=Members[Scopes=$].Name ``` returns the members whose `Scopes` contain `Scope:1067`. A top-level `where` has no asset to refer to, and sending `$` there is a server error. - **`$Search` is the current text search's result set.** When a request carries `find`, the matched asset IDs are bound to `$Search`, and a filter may reference them: ```text GET /rest-1.v1/Data/Story?find=login&findin=Name&where=ID=$Search&sel=Name&page=50,0 ``` The name is only defined while a `find` is present. Its value is the matched set itself, so the practical use is referencing search results from a *relation's* filter rather than the root asset's. Assigning either name yourself in `with` is not supported — treat both as reserved. A third server-supplied name, `#ContextOid`, belongs to the Bulk API's create commands rather than to filters — see [aliases and projection](/apis/asset-bulk/aliases-and-projection). ## Errors A `with` value that fails to parse produces `400 Bad Request` with the message `Invalid WITH parameter`. Note that because almost anything lexes as a `CONTEXT_VALUE`, a malformed assignment is far more likely to produce a successful query that matches nothing than an error. If a filter using a variable returns an empty set, check the `with` value for stray quotes first. ## The `query.v1` Equivalent `query.v1` carries the same concept as a mapping rather than a token, which removes the quoting trap entirely: ```json { "from": "Epic", "select": ["Number", "Name"], "filter": ["AssetState!='Closed';Scope=$Scope;-SuperAndUp[Scope=$Scope]"], "with": { "Scope": ["Scope:0", "Scope:1", "Scope:2"] } } ``` The parser reads the mapping keys verbatim, so whether the key needs the leading `$` is decided downstream — and is **not verifiable from the parser alone**. The example above omits it, matching how the reference in the filter expression supplies the `$`. If a `query.v1` filter using a variable returns nothing, try the key both ways. ## Related Topics - [Filter expressions](/reference/query-grammar/filter-expressions) - [Attribute paths](/reference/query-grammar/attribute-paths) - [Grammar reference](/reference/query-grammar/ebnf) --- # Sort and Paging - URL: https://docs.digital.ai/agility-api/reference/query-grammar/sort-and-paging - Markdown: https://docs.digital.ai/agility-api/reference/query-grammar/sort-and-paging.md - Source: reference/query-grammar/sort-and-paging.md - Description: Ordering results with + and -, and paging with page=size,start — including the argument order that is commonly reversed. - Tags: reference, query-grammar, sorting, pagination ## Sort ```text sort_token = [ sort_term , { "," , sort_term } ] ; sort_term = [ "+" ] , attribute_name | "-" , attribute_name ; ``` | Prefix | Direction | | ------ | ------------------------ | | `+` | Ascending — **the default** | | `-` | Descending | | none | Ascending | Terms are comma-separated and applied in order, so the first term is the major sort: ```text GET /rest-1.v1/Data/Story?sel=Name,Estimate&sort=Estimate GET /rest-1.v1/Data/Story?sort=-Estimate,+Name ``` The second sorts by Estimate descending, then by Name ascending within each Estimate. Each term is a full [attribute path](/reference/query-grammar/attribute-paths), so you can sort by a value reached through a relation: ```text ?sort=Scope.Name,-ChangeDate ``` :::note `+` and `-` mean something different in `where` In a [filter expression](/reference/query-grammar/filter-expressions#unary-existence) the same two characters are the unary existence operators: `-Estimate` there means "has no estimate", not "descending". The characters are shared; the meaning comes from which parameter they appear in. ::: A `+` in a URL query string is decoded as a space by many servers and clients. Since ascending is the default, the safest habit is to omit `+` entirely and write only the `-` prefixes you need. If you do send `+`, encode it as `%2B`. ### Default Ordering No ordering is guaranteed when `sort` is absent from a `/Data` request — do not depend on the order of an unsorted list request. There is one exception. On a **history** request with no `sort`, the server adds an implicit ascending sort on `Moment`. Supplying your own `sort` replaces it rather than adding to it, so a sorted history response is no longer chronological. ### Errors A `sort` value that does not parse, or that names a non-existent attribute, produces `400 Bad Request` with the message `Invalid SORT parameter`. `sort` is ignored without complaint on single-asset `/Data/{Type}/{id}` requests. Note that `deleted` and `needTotal` _are_ applied on those same URLs; see [which parameters apply to which request shape](/reference/query-grammar#which-parameters-apply-to-which-request-shape). ## Paging ```text paging_token = page_size , [ "," , page_start ] ; page_size = INT ; page_start = INT ; ``` :::danger Size comes first `page=,` — the **first** number is how many assets to return, the **second** is how many to skip. This is the opposite of the `(start, size)` ordering most pagination APIs use, and getting it backwards is silent: you get a valid response containing the wrong window. Note also that `query.v1` uses a **named** mapping — `page: { start: 0, size: 50 }` — so a query ported between the two surfaces cannot carry the ordering across. ::: `start` counts assets to skip, so it advances by `size` per page, not by one: ```text GET /rest-1.v1/Data/Story?sel=Name&page=5,0 # assets 1–5 GET /rest-1.v1/Data/Story?sel=Name&page=5,5 # assets 6–10 GET /rest-1.v1/Data/Story?sel=Name&page=5,10 # assets 11–15 ``` `start` is optional; `page=5` is `page=5,0`. Both values are plain non-negative integers — no sign, no decimal point. Because paging is applied after sorting, a stable `sort` is a prerequisite for correct paging. Paging an unsorted `/Data` list can repeat or skip assets between pages. ### Reading the Response A list response carries three attributes on its root element: ```xml ``` | Attribute | Meaning | | ----------- | -------------------------------------------------------------------- | | `total` | How many assets satisfied the query in total, ignoring paging — omitted when the server did not compute it | | `pageSize` | The requested page size | | `pageStart` | The requested page start | When `page` is omitted, `pageSize` comes back as `2147483647` — `Int32.MaxValue` — meaning the response is unpaged. Treat that value as "no limit", not as a real page size. **"No limit" means no limit.** An unpaged list request attempts to return every matching asset, so `page` is effectively mandatory on any query that could match more than a handful. See [Query size and performance](/guides/query-size-and-performance). :::danger A missing `total` means "not computed", not "no results" `total` follows two rules: - **Unpaged responses always carry it**, and it is always a real count — with no `page` the server counts what it returns. - **Paged responses carry it only when the server was asked to compute it.** Otherwise the attribute is omitted entirely (XML and JSON alike; it is never written as `-1` or `null` on this endpoint). On a paged query, whether the count is computed is controlled by the [`needTotal` parameter](/reference/query-grammar#parameter-map), and **its default is per-instance**: it comes from the `ApiDefaultNeedTotal` server setting. That is the trap — a client that drives a pager off `total` can work correctly against one Agility instance and break against another with no code change, because the default flipped underneath it. Always send `needTotal=true` explicitly when you need the count, and treat a missing `total` as "unknown" rather than as zero. `needTotal` is applied on both list and single-asset requests, unlike `page` and `sort`. ::: ### Errors A `page` value that is not an integer produces `400 Bad Request` with the message `Invalid PAGE parameter`. Extra comma-separated values beyond the second are ignored rather than rejected. `page` is ignored without complaint on single-asset `/Data/{Type}/{id}` requests, for the same reason `sort` is. ## Putting It Together ```text GET /rest-1.v1/Data/Story ?sel=Name,Estimate,ToDo &where=Estimate='5' &sort=ToDo &page=3,0 &find=Ticket &findin=Name ``` The first 3 of the Story assets sorted by their To Do, with their Name, Estimate and To Do attributes, that have an Estimate of 5 and a Name containing the word Ticket. ## Related Topics - [Attribute paths](/reference/query-grammar/attribute-paths) - [Filter expressions](/reference/query-grammar/filter-expressions) - [Grammar reference](/reference/query-grammar/ebnf) - [Query size and performance](/guides/query-size-and-performance) — why an unpaged request is a problem, and the request shape that avoids it - [Performance](/guides/performance) --- # Grammar Reference - URL: https://docs.digital.ai/agility-api/reference/query-grammar/ebnf - Markdown: https://docs.digital.ai/agility-api/reference/query-grammar/ebnf.md - Source: reference/query-grammar/ebnf.md - Description: The complete rest-1.v1 query syntax rendered as EBNF, with the raw ANTLR grammar files available for download. - Tags: reference, query-grammar, ebnf, antlr, grammar This page is the complete syntax in one place, rendered as EBNF. The prose pages — [attribute paths](/reference/query-grammar/attribute-paths), [filter expressions](/reference/query-grammar/filter-expressions), [filter context](/reference/query-grammar/filter-context) and [sort and paging](/reference/query-grammar/sort-and-paging) — explain what each production means; this page is for checking a form quickly. The EBNF below is a faithful transcription of the published ANTLR v3 grammar files, reordered for reading and with the ANTLR-specific machinery (token names, lexer actions, `EOF`) removed. Where the grammar and the server disagree, the server wins and the divergence is called out inline. ## Notation | Form | Meaning | | --------------- | -------------------------------- | | `=` | Defines a rule | | `,` | Concatenation | | `\|` | Alternatives | | `[ x ]` | Optional — zero or one | | `{ x }` | Repetition — zero or more | | `( x )` | Grouping | | `"x"` | A literal character or string | | `? … ?` | A condition described in prose | ## Entry Points `V1Query.g` is unusual in having four root rules rather than one, because the same vocabulary is reused by several query parameters. The table maps each root to the wire parameter that actually carries it. | Grammar root | Grammar file | Wire parameter | | ---------------------------- | ------------------- | ------------------------------------ | | `attribute_selection_token` | `V1Query.g` | `sel`, and `select` as an alias | | `filter2_token` | `V1Query.g` | **`where`** — there is no `filter=` | | `sort_token` | `V1Query.g` | `sort` | | `attribute_definition_token` | `V1Query.g` | the trailing path segment of a URL | | `filter_context_token` | `V1FilterContext.g` | `with` | | `paging_token` | `V1Paging.g` | `page` | The parameters `find`, `findin`, `asof`, `deleted` and `needTotal` have no grammar; they are plain scalar values. They are documented in the [parameter map](/reference/query-grammar#parameter-map). ## Selection, Sort and Attribute Definition ```text attribute_selection_token = [ attribute_name , { "," , attribute_name } ] ; sort_token = [ sort_term , { "," , sort_term } ] ; sort_term = [ "+" ] , attribute_name | "-" , attribute_name ; attribute_definition_token = asset_type , "." , attribute_name ; ``` ## Attribute Paths ```text attribute_name = attribute_name_part , { "." , attribute_name_part } , [ ".@" , aggregation_name ] ; attribute_name_part = NAME , [ now_only ] , [ downcast ] , [ attribute_filter ] ; now_only = "#" ; (* send as %23 *) downcast = ":" , asset_type ; attribute_filter = "[" , filter_expression , "]" ; asset_type = NAME ; aggregation_name = NAME ; (* see note *) ``` `aggregation_name` is a bare `NAME` in the grammar, but the server accepts only `Sum`, `Count`, `DistinctCount`, `Any`, `MinDate`, `MaxDate`, `And`, `Or` and `MaxState` — the grammar's own documentation comment lists these too, except `Any`, which it omits. See [aggregations](/reference/query-grammar/attribute-paths#aggregations). ## Filter Expressions ```text filter_expression = filter_term , { ( and_operator | or_operator ) , filter_term } ; filter_term = grouped_filter_term | simple_filter_term ; grouped_filter_term = "(" , filter_expression , ")" ; simple_filter_term = attribute_name , [ binary_operator , ( filter_value_list | variable ) ] | unary_operator , attribute_name ; and_operator = "&" | ";" ; or_operator = "|" ; binary_operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~=" ; unary_operator = "+" | "-" ; filter_value_list = filter_value , { "," , filter_value } ; filter_value = single_quoted_string | double_quoted_string ; variable = context_asset | variable_name ; ``` `filter_expression` is a flat left-associated repetition: **AND and OR have the same precedence.** Parenthesise when mixing them. ## Filter Context ```text filter_context_token = [ filter_context_term , { "|" , filter_context_term } ] ; filter_context_term = variable_name , "=" , filter_context_values ; filter_context_values = context_value , { "," , context_value } ; context_value = ? any run of characters other than "," and "|" ? ; ``` `context_value` is deliberately permissive because the real implementation is a token splitter, not a parser — `V1FilterContext.g` says so in its own preamble and models it with a lexer mode flag. Values here are **not** quoted, unlike values in a filter expression. See [filter context](/reference/query-grammar/filter-context). ## Paging ```text paging_token = page_size , [ "," , page_start ] ; page_size = INT ; page_start = INT ; ``` Size first. See [sort and paging](/reference/query-grammar/sort-and-paging#paging). ## Lexical Rules ```text single_quoted_string = "'" , { ? any character except "'" ? } , "'" ; double_quoted_string = '"' , { ? any character except '"' ? } , '"' ; context_asset = "$" ; variable_name = "$" , NAME ; NAME = name_char , { name_char } ; name_char = alpha | digit | "_" ; alpha = "A".."Z" | "a".."z" ; digit = "0".."9" ; INT = digit , { digit } ; ``` Three things follow from these lexical rules that are easy to miss: - **Quoted strings have no escape sequence.** A value cannot contain its own delimiter, and no value can contain both `'` and `"`. - **`NAME` excludes `-`, spaces and every other punctuation character.** Asset type names and attribute names are alphanumerics and underscores only. - **There is no whitespace rule.** The grammars define no skipped-whitespace token, so do not put spaces around operators in a query token. ## Structure at a Glance The reuse of `attribute_name` is what makes the language compact — and what makes it recursive, since a filter attached to a path part contains a filter expression, which contains attribute paths of its own. ```text attribute_selection_token ─┐ sort_token ────────────────┼──▶ attribute_name ──▶ attribute_name_part ──┬──▶ now_only attribute_definition_token ┘ ├──▶ downcast └──▶ attribute_filter │ filter2_token ──▶ filter_expression ◀───────────────────────────────────────────────┘ │ ├──▶ grouped_filter_term ──▶ filter_expression (recursion) └──▶ simple_filter_term ──┬──▶ attribute_name (recursion) ├──▶ filter_value_list └──▶ variable ``` ## The Raw Grammar Files The three ANTLR v3 files are published verbatim, including their modified-BSD license headers. They are a machine-readable contract: fetch them if you are building a parser, a linter or a query builder. | File | Bytes | Covers | | ------------------------------------------------------------- | ----- | ---------------------------------------------------- | | [`V1Query.g`](pathname:///grammars/V1Query.g) | 9,608 | Selection, attribute paths, filter expressions, sort | | [`V1FilterContext.g`](pathname:///grammars/V1FilterContext.g) | 1,882 | The `with` parameter | | [`V1Paging.g`](pathname:///grammars/V1Paging.g) | 1,118 | The `page` parameter | They are published unmodified; the modified-BSD license header at the top of each file is intact. A [README](pathname:///grammars/README.md) sits alongside them recording their provenance and caveats. :::note Read the grammars as a syntax map, not as an oracle The files were last revised in 2012 and the upstream project states that they describe the intended syntax rather than the shipped implementation. Eight known divergences are catalogued in [grammar versus implementation](/reference/query-grammar#grammar-versus-implementation). The most consequential is the naming of `filter2_token`, which implies a `filter=` query parameter that does not exist. ::: ## Related Topics - [Query grammar overview](/reference/query-grammar) - [Attribute paths](/reference/query-grammar/attribute-paths) - [Filter expressions](/reference/query-grammar/filter-expressions) - [Filter context](/reference/query-grammar/filter-context) - [Sort and paging](/reference/query-grammar/sort-and-paging) --- # API Glossary - URL: https://docs.digital.ai/agility-api/reference/glossary - Markdown: https://docs.digital.ai/agility-api/reference/glossary.md - Source: reference/glossary.md - Description: The vocabulary of the Agility API - asset model terms, query terms, and the UI names that differ from them - Tags: reference, glossary API names first, UI display names in parentheses — instances can customize labels, so code should always use the API name and OIDs. ## The Asset Model - **Asset** — any entity in the system: work items, projects, members, even the schema itself. Identified by an [OID](#identifiers). - **AssetType** — an asset's type (`Story`, `Scope`, `Member`), arranged in an [inheritance hierarchy](/concepts/assets#the-asset-type-hierarchy). Abstract types (`Workitem`, `PrimaryWorkitem`) can be queried but not created. - **Attribute** — a named property of an asset; [defined in meta](/concepts/attributes) with a type, requiredness, writability. - **AttributeDefinition** — the meta description of an attribute — itself a queryable asset. - **Relation** — an attribute whose value is another asset (single) or several (multi). Every relation has a **reciprocal** on the other side. - **Custom attribute** — admin-defined attribute; token usually starts `Custom_`, flagged `IsCustom` in meta. - **Scope** (*Project*) — the container and permission boundary for work; forms a tree rooted at `Scope:0`. - **Scheme** (*Project Workspace*) — controls which list values and roles a project offers; carries [Important Fields](#lifecycle-and-status). - **Timebox** (*Iteration/Sprint*) — a planning period, provided to scopes through a shared **Schedule**. - **Epic** (*Portfolio Item*) — portfolio-level work; nests via `Super`/`Subs`. - **Theme** (*Backlog Group*) — classification hierarchy; primary workitems point at one via `Parent`. - **PrimaryWorkitem** — abstract parent of `Story`, `Defect` and `TestSet`; the "backlog item" level. - **Team Process** — a team's own set of `StoryStatus` values, enforced on status change; its Active-rollup subset is the team's **WIP cycle** (advisory, with the **WIP limit**). ## Identifiers - **OID** — `AssetType:id` (`Story:1207`); the permanent identifier used in API calls. A third part (`Story:1207:5837`) names a **moment**. - **Moment** — a point-in-time version of an asset; see [moments and history](/concepts/moments-and-history). - **Number** — the human-readable display number (`S-01001`, `D-01004`); use with people, not in code. - **Moniker** — an asset reference by name in place of an OID token, accepted by Bulk API relation values; a backslash path through the type's naming context (`Offerings\Agility\Optimize`) narrows it, and it must resolve to exactly one asset. See [creating assets](/apis/asset-bulk/creating-assets#qualifying-a-name-with-a-path). ## Lifecycle and Status - **AssetState** — the system lifecycle: Future (0), Active (64), Closed/**Inactive** (128 — two names, one value), dead states from 192, Deleted (255 — still stored and recoverable). *Not* the same thing as Status. - **List** — the abstract base asset type of every dropdown-value type (`StoryStatus`, `Priority`, …). The name means *list entry*, not the list: each value is-a `List`; the list as a whole is just every asset of the concrete type. - **Status** — the workflow position: a relation to a list value (`StoryStatus`, shared by all primary workitems). Instance data — match on OID. - **RollupState** — a status value's lifecycle category (Future/Active/ Closed) on `StoryStatus` and `EpicStatus`; [what reports actually read](/concepts/workflows#rollupstate). - **EffectiveAssetState** — the reporting state: an Active asset reports its status's RollupState. Drives **DoneDate/DoneDateUTC** (stamped at effective closure). - **IsDeleted** — soft-delete flag; deleted assets need both `?deleted=1` and an `IsDeleted` filter to query. - **Operation** — a named lifecycle action (`Inactivate`, `Delete`, `Copy`…) invoked with `?op=` or a Bulk API execute command — distinct from attribute updates. - **Important Fields** — scheme-flagged attributes the UI calls out; can be made mandatory at close (`IsRequiredAtClose`). ## Querying - **where** — exact-match filtering in a query document. - **filter** — the full [comparison grammar](/reference/query-grammar); values quoted. - **find / findin** — text search (substring semantics — unlike `~=`, which is [full-text](/reference/query-grammar)). - **asof** — point-in-time querying, available on `query.v1` and the Bulk API's query command. - **page** — `{size, start}` in query documents; positional size-first (`page=50,0`) on rest-1. **There is no default page size.** - **needTotal** — opt-in result count; without it a paged response omits `total` (rest-1) or reports it as `null` (Bulk API). Prefer [stopping on a short page](/guides/query-size-and-performance#paging-correctly). - **Downcast** — narrowing a relation to a subtype in a path (`Children:Task`). - **Rank** — the opaque ordering type behind `Order` attributes: sort by it, never read or compare it. ## Surfaces - **Bulk API** — `api/asset`: queries, creates, updates and operations in one request; the [one-stop shop](/getting-started/choosing-an-api). Responds with `assetsCreated` / `assetsModified` / `assetsOperatedOn` / `commandFailures` / `queryResult`. - **Alias** — `@name` labels that let later Bulk commands reference earlier ones, [with limitations](/apis/asset-bulk/aliases-and-projection). - **rest-1.v1** — per-asset REST: `Data` (current), `Hist` (change history), `New` (defaulted templates). - **meta.v1** — the schema, anonymous by design. - **loc-2.v1** — localization: resolves terminology and [some error tags](/apis/conventions/errors#violation-tags-are-localizable); unmatched tags echo back. - **Base URL** — `https://host/instance/` (e.g. `https://www7.v1host.com/MyInstance/`); see [base URL conventions](/apis/conventions/base-url), including the `rest-1.v1/query.v1` URL that works only by accident. ## Related Topics - [Concepts](/concepts/assets) — the model in prose - [Query grammar](/reference/query-grammar) — the syntax in full - [Attribute types](/reference/attribute-types) --- # HTTP Status Codes Reference - URL: https://docs.digital.ai/agility-api/reference/status-codes - Markdown: https://docs.digital.ai/agility-api/reference/status-codes.md - Source: reference/status-codes.md - Description: What each HTTP status actually means on the Agility API, with real response bodies - Tags: reference, http, errors What each status actually means on this API — with the codes you might expect but will not meet. The companion guide is [error handling](/guides/error-handling); when a `200` carries wrong data, start at [troubleshooting](/troubleshooting). ## 200 OK — but Check the Body `200` means the request was **understood**, not that everything worked: - **`query.v1`** returns its array-of-arrays envelope — an empty inner array is a successful query with no matches, which is also what a too-strict filter or a [silently-repaired search](/reference/query-grammar) produces. - **`api/asset`** returns `200` even when commands failed — the failures are itemised in `commandFailures`. **Check `commandFailures.count` on every response.** See [errors and partial failures](/apis/asset-bulk/errors). - **`rest-1.v1`** creates, updates and operations all answer `200` with the asset (including the new moment OID). So do [webhook creations](/apis/webhooks/create). ## 400 Bad Request A violation or a bad value, usually named in the body: - **Violation tags** — `Violation'AssetCannotDeleteClosed`, `Violation'ClosedAssetCannotEditExistingValues`, `Violation'PrimaryWorkitemCannotHaveInvalidStatus` — many are [resolvable to readable text](/apis/conventions/errors#violation-tags-are-localizable) - **Type errors** — `Wrong OID AssetType: Epic:3889` when a relation's value has the wrong type - **Strict endpoints** — [`api/webhook`](/apis/webhooks/create) rejects unknown keys with a `400` and a message ### Query Parameter Errors {#query-parameter-errors} A malformed value in a `rest-1.v1` query parameter returns `400` with a `Message` naming the parameter, and a nested `Exception` carrying the detail (such as `Unknown AttributeDefinition: Story.Bogus`): - `Invalid SEL parameter` - `Invalid WHERE parameter` - `Invalid WITH parameter` - `Invalid FIND or FINDIN parameter` - `Invalid SORT parameter` - `Invalid PAGE parameter` ## 401 Unauthorized Three distinct causes, one code: missing/invalid credentials, an Agility-issued token that was revoked, or **access tokens disabled instance-wide**. The response carries a `WWW-Authenticate` challenge. See [authentication errors](/getting-started/authentication#common-authentication-errors). ## 403 Forbidden Authenticated, but the member's role lacks the right for that asset or scope — see [roles and permissions](/reference/roles-and-permissions). ## 404 Not Found No such route, or no such asset at a rest-1 URL. (A query for a nonexistent asset is not a 404 — it is a `200` with an empty result set.) ## 405 Method Not Allowed The wrong HTTP method for the route. The famous case: [`api/customFields` is a POST](/apis/field-visibility/custom-fields), and a `GET` gets a `405`. ## 500 Internal Server Error On `api/asset`, a `500` almost always means **your body never parsed** — malformed JSON/YAML, or well-formed input in an impossible shape (the [sibling-alias trap](/apis/asset-bulk/aliases-and-projection#limitations)). The response is opaque: ```json { "Exceptions": ["Server Error", "Reference: 76894036-5f86-49fd-bb4f-58970c644e2c"] } ``` Keep the reference GUID — it is the only handle support has. Full treatment: [errors and partial failures](/apis/asset-bulk/errors). ## Codes You Might Expect but Will Not Meet | Expected | Reality | | -------- | --------------------------------------------------------------------------- | | `201` | Creates return `200` with the asset — on rest-1, api/asset and api/webhook | | `204` | Deletes are operations (`?op=Delete`) returning `200` with the asset | | `429` | Returned when a request exceeds a [configured quota policy](/guides/rate-limiting): always a possibility on Digital.ai-hosted instances, and on-premises only where an administrator has enabled rate limiting. The body identifies which limit was exceeded and how long to wait before retrying; a `V1-Limit-Reason` header echoes the reason. Honor `RetryAfter` and back off | ## Related Topics - [Error handling guide](/guides/error-handling) - [Error conventions](/apis/conventions/errors) - [Troubleshooting by symptom](/troubleshooting) --- # About This Documentation - URL: https://docs.digital.ai/agility-api/reference/sources - Markdown: https://docs.digital.ai/agility-api/reference/sources.md - Source: reference/sources.md - Description: What this site covers, how it is verified, and where product documentation lives. This site is the developer reference for the Digital.ai Agility API: the endpoints, the query syntax they share, and the behaviour you can rely on when building an integration. Its content is validated against Agility's implementation and its executable test suites. Where a behaviour is not covered by a test, or varies with an instance's configuration, the page says so and recommends verifying against your own instance. Two boundaries keep this site focused: - **It documents the API, not the product.** Product concepts, tutorials and end-user workflows live in the [Digital.ai Agility documentation](https://docs.digital.ai/agility/). - **It documents the supported surface.** A small number of `api/*` routes exist for internal or infrastructure purposes. They are deliberately not documented here, and integrations should not be built against them. ## Feedback Spotted an error, a gap, or behaviour that does not match your instance? There are two ways to reach the maintainers, depending on who you are: - **Digital.ai team members** — open an issue or propose a change in [digital-ai/docs-agility-api](https://github.com/digital-ai/docs-agility-api/issues). The repository is internal, so this link requires Digital.ai enterprise access and returns a 404 for everyone else. - **Everyone else** — contact [Digital.ai Support](https://support.digital.ai/hc/en-us). Include the page URL and, where possible, the request and response that surprised you — that is usually enough to reproduce and fix a documentation error quickly. --- # Attribute Types - URL: https://docs.digital.ai/agility-api/reference/attribute-types - Markdown: https://docs.digital.ai/agility-api/reference/attribute-types.md - Source: reference/attribute-types.md - Description: The complete list of Agility attribute types reported by meta.v1, with the underlying data type of each. - Tags: reference, meta, attributes, types Every `AttributeDefinition` returned by [meta.v1](/apis/meta-v1) carries an `attributetype` attribute whose value is one of the following. ## List of Attribute Types {#appendix-table-attributetypes} | Attribute Type | Data Type | Description | | --- | --- | --- | | Boolean | boolean | true or false | | Numeric | double | floating point number | | Date | datetime | date and possibly time | | Duration | string | a string with syntax N UnitType where N is an integer and UnitType is Days, Weeks, or Months | | Text | string | string limited to 4000 characters | | LongText | string | string limited by database. Often contains html or rich text | | Relation | Oid | a relationship to another asset | | Rank | Rank | An arbitrary object that represents one rank object relative to another | | AssetType | AssetType | the name or token of an asset type | | Opaque | Opaque | An internal value that has no external meaning. | | State | integer | The asset's lifecycle state. Rendered as a number by `rest-1.v1` and by name everywhere else; see [AssetState values](#assetstate-values). | | Password | string | A string that represents the password for a login. A password attribute type is write-only. It can never be retrieved. | | Blob | Blob | A stream of binary data such as the content of an Attachment asset. | ## AssetState Values {#assetstate-values} Every asset carries `AssetState`, a `State` attribute. The stored value is a byte; the named values are the meaningful points on it: | Value | Number | Meaning | | -------------------- | -----: | -------------------------------------------------------------------------------------- | | `Future` | 0 | Not yet active (a story in a future timebox, a scope not started). | | `Active` | 64 | In use. | | `Closed` | 128 | Closed; for a List value, inactive. | | `Dead` | 192 | Retired. | | `OldBrokenDownStory` | 208 | A story that was broken down under the old breakdown model. | | `Deprecated` | 254 | Deprecated. | | `Deleted` | 255 | Deleted. Excluded from reads unless `deleted=true` (rest-1) or `IsDeleted` is queried. | **The endpoints do not agree on how to render it.** `rest-1.v1` renders the number (`128`, `"value": 128` in JSON); `query.v1` and `api/asset` render the name (`"AssetState": "Closed"`). There is no way to ask rest-1 for the name: `AssetState` is a `State`, not a relation, so `AssetState.Name` and `AssetState.Code` are rejected (`Unknown token`), and a list value's `RollupState` (`Status.RollupState`) renders as a number too. What rest-1 does offer readably is the derived booleans `IsClosed`, `IsDead`, `IsDeleted` and `IsInactive`; otherwise map the number with the table above. A client that reads through more than one endpoint should normalise to the name once, at the edge, rather than compare a `128` from one call with a `"Closed"` from another. **Filters accept either spelling, and they are not plain comparisons.** On every endpoint: - The value may be a name (case-insensitive) or a number that is one of the seven codes: `AssetState='Closed'`, `AssetState='closed'` and `AssetState='128'` are the same filter. An unknown name is rejected (`Invalid QueryFilter2 token`), but a number that is not one of the codes is accepted and compared exactly: `AssetState='200'` finds templates. - Only `=` and `!=` are allowed. `AssetState<'Closed'` is rejected (`InequalityNotSupported`), even though the value is a number underneath. - Equality on a name is a **band**, not a point: `Future` matches anything below 64, `Active` anything from 64 up to 128, `Closed` from 128 up to 192, `Dead` from 192 up to 255, and `Deleted` exactly 255. So `AssetState='Dead'` also matches an asset stored as 200 or 208. `OldBrokenDownStory` and `Deprecated` match their exact code. - `!=` on a name means **below the band's start**, which is where the ordinary filters get their meaning: `AssetState!='Closed'` is "state < 128" (Future and Active only; it excludes Dead and Deleted too), `AssetState!='Dead'` is "state < 192" (everything not retired or deleted), `AssetState!='Active'` is "below 64 or 128 and above". - A list ORs the bands (`AssetState='Active','Future'`), and a negated list ANDs the negations. --- # Roles and Permissions - URL: https://docs.digital.ai/agility-api/reference/roles-and-permissions - Markdown: https://docs.digital.ai/agility-api/reference/roles-and-permissions.md - Source: reference/roles-and-permissions.md - Description: The thirteen roles Agility supports, how Rights differ from Privileges, and what actually separates one role from another — written for integrators provisioning Members. - Tags: reference, roles, permissions, rights, privileges, members If you are provisioning Members through the API, the role you assign is the single most consequential field on the request — and until now it has not been documented anywhere public. This page covers the roles Agility supports and what genuinely separates them. ## Rights and Privileges Are Not the Same Thing Agility has two distinct permission mechanisms, and the distinction is the thing most worth understanding before assigning anything. | | **Rights** | **Privileges** | | --- | --- | --- | | Apply | Within a Scope | Across **all** Scopes | | Depend on | The Scope of the asset being acted on, where that asset has a SecurityScope | Nothing — they are global | | Typical use | Viewing and editing work: stories, tasks, tests, defects, actuals | Administration: managing members, roles, metadata, teams, tags | **Rights are scoped.** Whether a member may edit a Story depends on which project that Story is in. The same member can hold different rights in different projects. **Privileges are global.** A member who can manage metadata can manage it everywhere; there is no per-project version of that permission. :::warning Three names appear in both, meaning different things `ManageSecurity`, `ManageConfig` and `ManageSchedules` exist as **both** a Right and a Privilege. They are not the same permission — the Right is scoped to a project, the Privilege is instance-wide. If you are reading a permission name out of context, establish which of the two it is before concluding what a member can do. A member holding the `ManageConfig` *Right* on one project is not the same as a member holding the `ManageConfig` *Privilege*. ::: ## `DefaultRole` Is the Global One On a Member, **`DefaultRole` is what the UI presents as Admin Privileges**. It does two things: 1. Determines the member's **global privileges**. 2. Determines the role they will act as **by default when granted access to a new Scope**. A member's role *on a particular project* is separate and can differ. Reading [`api/ProjectRoles`](/apis/project-roles) returns both, precisely because they answer different questions. When provisioning, set `DefaultRole` deliberately — it is not merely a default, it is the carrier of global privileges. ## The Thirteen Roles ### Administrative All three have full editing capability within projects. They differ in what they may administer across the instance. | Role | Distinguishing capability | | --- | --- | | **System Admin** | Everything. The only role that can administer **metadata, roles, lists and OKRs**, and the only one that can override base privileges. | | **Member Admin** | Everything System Admin can do within projects, plus **member administration** and member labels. Cannot administer metadata, roles or lists. | | **Project Admin** | Full project capability plus **project-level configuration**, tags, project labels and test integration. **Cannot administer members** — the main line between it and Member Admin. | ### Project Delivery | Role | Can edit | Cannot | | --- | --- | --- | | **Project Lead** | Everything within the project, including project settings, budgets, roadmap, rooms, teams and schedules | Manage security | | **Team Lead** | All work items — themes, stories, tasks, tests, defects, issues, actuals, retrospectives, capacities, milestones | Edit the project itself, manage security, schedules, rooms or teams | | **Developer** | Tasks, actuals, defects, issues, capacities, milestones | **Stories, tests and themes** | | **Tester** | Tests, regression tests, actuals, defects, issues, capacities, milestones | **Stories and tasks** | | **Customer** | Themes, stories, tests, goals, strategic themes, roadmap, defects, issues | **Tasks and actuals** — the execution side | The three specialist roles are the ones integrators most often get wrong. **Developer and Tester are near-mirrors**: Developer edits tasks but not tests, Tester edits tests but not tasks, and neither can edit stories. **Customer is the requirements role** — it can shape what gets built but not record how the work is executed. ### Read and Submit | Role | What it can see | | --- | --- | | **Requestor** | Everything Observer sees, **plus the ability to submit** — it holds general write but cannot edit work items | | **Observer** | Full read across work items. No write of any kind | | **Visitor** | Limited read: scope, themes, stories, defects, goals and roadmap | | **Inheritor** | Minimal read: scope, themes, goals and roadmap. Used where access is inherited from a parent project | | **No Access** | Nothing | **Requestor and Observer see exactly the same things.** The only difference is that Requestor can submit. If you are provisioning someone who needs to raise requests but not edit work, that is Requestor; if they need to look and nothing more, that is Observer. ## Match on Role ID, Never on the Name **Role IDs are fixed.** The OID token identifying a role is stable, so it is the safe thing for an integration to match on. **Role names are not.** Agility almost never displays a role's `Name` directly — it runs it through the localizer first. Three consequences follow, and each one breaks integrations that match on strings. ### The Displayed Name Can Differ from the Stored Name A role's stored `Name` is a localization tag of the form `Role.Name'