Skip to content

DADL Specification v0.2

Previous versions: v0.1 (frozen, Markdown) · version manifest (JSON)

Specification v0.2

A declarative YAML format for describing REST APIs as ToolMesh backends. Write a .dadl file — ToolMesh handles the rest.

Version0.2.0
Date2026-08-05
AuthorDunkel Cloud GmbH
LicenseCC BY-SA 4.0

Changes from v0.1 (additive — every valid v0.1 file is a valid v0.2 file):

  • Section 5.3: new flow: refresh_token for auth.type: oauth2 (user-delegated APIs such as Google or Microsoft Graph), with the new field refresh_token_credential. Files using this flow MUST declare spec v0.2.
  • Section 5.3: two more oauth2 flows — jwt_bearer (service accounts, RFC 7523; e.g. Google Search Console and Workspace APIs) and authorization_code (three-legged consent driven by toolmesh setup, refresh token persisted in the credential store; e.g. YouTube).
  • Section 4: documented defaults.content_type (backend-wide default request content type; implemented since v0.1 but previously undocumented).
  • Sections 4/6: documented defaults.nest_body_keys and its per-tool override — dotted in: body parameter names nest into body objects (Section 6.1; implemented since v0.1 but previously undocumented).
  • Section 5.5: corrected the API-key auth type to its implemented spelling apikey (the v0.1 document said api_key, which ToolMesh has never accepted; the canonical schema accepts both) and documented query_param for inject_into: query (implemented since v0.1 but previously undocumented).
  • Section 4: corrected base_url to optional (the v0.1 document said required; the runtime has always treated it as optional — self-hosted APIs get their URL from the deployment’s backends.yaml).
  • Section 6: normative override semantics — a tool-level response, errors, or pagination object replaces the corresponding defaults object; response.redact is the deliberate exception and merges additively.
  • Section 11.1: YAML merge keys (<<) are now discouraged (shallow-merge data loss, dropped from YAML 1.2, rejected by the public registry); examples use whole-node anchors.
  • Section 12.3: composites can carry an access classification, mirroring tools (Section 6.4); for the authorization semantics of their inner calls see the fail-closed item below.
  • Section 4.6: optional health declaration (absent = no check, nothing runs). Two forms: reference a declared tool or an inline endpoint. A declared check is exposed as a synthetic health tool returning a standardized result — including an optional auth_expires_at (from auth_expires_path or deployment metadata) for proactive credential-expiry warnings. For toolmesh setup, monitoring, and LLM self-diagnosis.
  • Section 6: new per-tool fields returns (typed results, Section 6.5), idempotency (safe write retries, Section 6.6), and deprecated / replaced_by (migration paths, Section 6.7).
  • Section 8.2: new errors.map — HTTP status codes are mapped to semantic error codes (not_found, conflict, rate_limited, …) so Code Mode error handling can branch on stable values.
  • Section 9.3: new response.redact — declarative masking of sensitive response fields via JSONPath list, complementing the Output Gate.
  • Section 3: new optional top-level requires block — minimum runtime version and feature requirements (fail-closed).
  • Section 15: new Conformance chapter — canonical JSON Schema, document/consumer conformance, unknown-key policy, unknown-value policy for behavior-determining enums, requires bootstrap limitation.
  • Section 16: non-normative outlook on v0.3 (session semantics for LLM backends).
  • Section 6: documented HEAD as a supported HTTP method (implemented since v0.1 but previously undocumented).
  • Section 5.3: PKCE (RFC 7636, S256) specified for public authorization_code clients; RFC 7523 sub-claim note for jwt_bearer.
  • Section 8: the error-mapping trigger (non-2xx) and the default for unlisted statuses are now explicit; catch-all codes client_error / server_error / unexpected_status; automatic retries MUST NOT re-execute non-idempotent calls (new opt-in retry_unsafe).
  • Section 9.4: the DADL JSONPath dialect is pinned — RFC 9535 syntax and semantics for name, index, and wildcard selectors.
  • Section 5.3: refresh-token rotation supported via rotates_refresh_token (atomic persistence rules; feature identifier refresh_token_rotation); declarative authorization_params, redirect_uri, and token_auth replace provider-specific behavior.
  • Section 12.3: composite authorization is fail-closed by default (inner calls re-checked against the caller); deliberate encapsulation requires declared delegates plus deployment-policy approval.
  • Section 6.2: blob handles (tm-blob://<blob-id>) let callers reference broker-stored files in any parameter, with #base64 / #dataurl / #url materialization (new section 6.2.4). File broker endpoints are now normative (section 6.2.3). This is runtime behavior of the caller-facing tool interface — existing DADL files need no changes and no spec-version bump to benefit.
  • Sections 6.2 / 9.2 / 15.2: consumer security requirements for file_url fetching (SSRF hardening), ad-hoc jq sandbox limits, and the registry publication profile (mandatory access).
  • Section 15.4: consumer conformance restructured into three profiles (Document Validator, Core Runtime, Full Runtime).

Compatibility notes (why the additivity claim above holds, item by item):

TopicStatus
auth.type: api_key (v0.1 spelling)Runtimes and validators MUST accept it as an alias for apikey (Section 5.5).
YAML merge keys (<<, shown in v0.1 §11.1)Merge keys are resolved by the YAML parser before validation — document conformance is unaffected. The public registry’s rejection of << is pre-existing CI policy, not a v0.2 conformance rule; Section 11.1 now documents the shallow-merge pitfall that motivated it.
Hint values (v0.1: “key-value pairs”)v0.2 pins values to scalars. This documents long-standing validation practice (the registry schema always required scalar values) and is a relaxation of that practice (numbers and booleans are now accepted alongside strings).
Tool-level response/errors/pagination overridesThe replace-not-merge semantics in Section 6 document behavior ToolMesh has always implemented; v0.2 adds the redact additive exception on top. No existing file changes behavior.
params without in:Now rejected at validation time (Section 6.1). This surfaces a bug rather than changing behavior: such parameters were silently dropped by the runtime since v0.1 — a file relying on them was already broken.
inject_into: query without query_paramDocuments declaring v0.2 MUST name the query parameter (validator rule, Section 15.2) — an injection without a parameter name cannot work. The canonical schema keeps query_param optional so that v0.1 files (which could not know the field) remain schema-valid.

DADL (Dunkel API Description Language) is a declarative YAML format that describes REST APIs for consumption by ToolMesh — a secure execution layer between AI agents and enterprise infrastructure.

Instead of building a dedicated MCP server for each REST API, you write a .dadl file. ToolMesh reads it, generates TypeScript interfaces, and exposes the API via Code Mode — two tools (search + execute) that give any AI agent access to the entire API in roughly 1,000 tokens.

# Without DADL
Claude → ToolMesh → custom Go/TS MCP Server → REST API
# With DADL
Claude → ToolMesh → REST API (via declarative .dadl file)

Code Mode only. DADL backends are always exposed via Code Mode. The LLM writes JavaScript against auto-generated TypeScript interfaces. No tool-per-endpoint explosion — regardless of API size.

Normative keywords (MUST, SHOULD, MAY, …) are used throughout this document as defined in Section 15.1.

This document mixes three concerns, marked as such where they appear: the portable DADL document format (normative for every consumer), ToolMesh runtime behavior (normative for ToolMesh; other consumers implement the equivalent contract), and registry/deployment policy (publication profiles and operator configuration — explicitly outside the document format). A future revision may split these into separate profiles.


Describe the API, not the agent behavior. DADL declares what endpoints exist and how to authenticate. ToolMesh decides how to present them to the LLM (always Code Mode). Temporal handles durability. OpenFGA handles authorization.

  • YAML-native — every .dadl file is valid YAML. Existing editors, linters, and parsers work out of the box.
  • Code Mode only — no tool-grouping syntax, no scope-exposure mechanics. The LLM writes code against TypeScript interfaces.
  • OpenAPI-compatible — optional openapi_source field uses an existing OpenAPI spec for schemas. DADL adds only what OpenAPI lacks: credential injection, pagination strategy, response transformation.
  • No templating — no variables, no conditionals, no loops. DADL is declarative, not generative.
  • No workflow syntax — multi-step orchestration happens in Code Mode (the LLM writes sequential code) and Temporal (durability, retry, audit).

A DADL file has the extension .dadl and is a YAML document with the following top-level fields:

FieldTypeRequiredDescription
specstringyesURL of the DADL specification this file conforms to. Currently "https://dadl.ai/spec/dadl-spec-v0.2.md" (files not using v0.2 features may keep declaring v0.1)
requiresobjectnoMinimum runtime requirements (toolmesh semver range, features list). A runtime that cannot satisfy them MUST refuse to load the file. See Section 15.3.
creditsarray of stringsnoFree-form list of contributors, maintainers, and sponsors. Each entry is a plain string — conventions emerge from usage (e.g. "Jane Doe (@janedoe)", "Acme Corp — sponsor").
source_namestringnoName of the source API being described (e.g. "GitHub REST API")
source_urlstringnoURL to the original API specification or documentation
datestringnoCreation or last-modified date of this file (YYYY-MM-DD)
backendobjectyesThe backend definition
includesarraynoReusable fragments to merge in
_*anynoUnderscore-prefixed keys are ignored by ToolMesh (used for YAML anchors)
# minimal.dadl
spec: "https://dadl.ai/spec/dadl-spec-v0.2.md"
credits: # optional
- "Jane Doe (@janedoe)"
- "Acme Corp — verifies against production"
source_name: "Example REST API" # optional
source_url: https://docs.example.com/api # optional
date: "2026-03-26" # optional
backend:
name: my-api
type: rest
version: "1.0"
base_url: https://api.example.com/v1
description: "My REST API"
auth:
type: bearer
credential: vault/my-api-token
tools:
list_items:
method: GET
path: /items
access: read
description: "List all items"

FieldTypeRequiredDescription
namestringyesUnique backend identifier (slug format: lowercase, hyphens)
typestringyesAlways rest for DADL backends
versionstringnoSemantic version of this DADL file (e.g. "1.0", "1.2.1"). Used by ToolMesh to detect available upgrades from the registry. See Section 4.5.
base_urlstringnoBase URL for all API requests. Omit for self-hosted APIs (BookStack, NetBox, GitLab, …) where every installation has its own URL — the deployment’s backends.yaml url: entry supplies it, and always overrides a declared base_url. (corrected in v0.2: the v0.1 document said required; the runtime has always treated it as optional)
descriptionstringyesHuman-readable description (used in Code Mode prompt)
openapi_sourcestringnoPath or URL to OpenAPI 3.x spec. When provided, schemas and parameters are derived from it.
arazzo_sourcestringnoPath or URL to Arazzo workflow file. Used as documentation context for Code Mode, not executed.
authobjectyesAuthentication configuration
defaultsobjectnoDefault headers, pagination, error, and response config for all tools. Supports headers (map of default HTTP headers), content_type (default request-body content type; per-tool content_type overrides it), nest_body_keys (dotted in: body parameter names nest into body objects; Section 6.1), pagination, errors, and response. content_type governs body encoding and the Content-Type header — do not additionally set defaults.headers.Content-Type; when both are present, content_type wins.
typesobjectnoType definitions (JSON Schema subset). Only needed without openapi_source.
toolsobjectyesMap of tool definitions
compositesobjectnoMap of composite tool definitions (server-side TypeScript). See Section 12.
examplesarraynoCode examples for multi-step workflows (few-shot prompts for the LLM). See Section 4.4.
coverageobjectnoAPI coverage metadata. Helps LLMs understand scope and users assess fitness.
hintsobjectnoPer-tool domain knowledge for LLM consumers (structured key-value). Injected into tool descriptions at load time. Subject to security scanning.
setupobjectnoHuman-readable setup instructions. Describes how to obtain credentials, configure backends.yaml, and required permissions. Powers toolmesh setup <name> CLI.
healthobjectnoHealth-check declaration: which cheap, side-effect-free call verifies this backend (a declared tool or an inline endpoint). Absent = no check. See Section 4.6.

Optional metadata describing how much of the target API this DADL file covers. Useful for discovery, community contributions, and LLM decision-making.

FieldTypeRequiredDescription
endpointsintegernoNumber of tools defined in this DADL file
total_endpointsintegernoEstimated total number of REST endpoints in the target API
percentageintegernoApproximate coverage percentage (0–100)
focusstringnoComma-separated list of covered API areas (e.g. “repos, issues, PRs, search”)
missingstringnoNotable uncovered API areas (e.g. “webhooks, teams, code scanning”)
last_reviewedstringnoISO 8601 date when coverage was last verified (e.g. “2026-03-26”)

Structured domain knowledge that is injected into tool descriptions at load time. Helps LLMs use tools correctly without trial and error. Hints are per-tool and use key-value pairs rather than free text to reduce prompt injection surface. Hint values are scalars (strings, numbers, booleans) — nested objects or arrays are not allowed.

Security: Hint values are subject to automated security scanning. DADL files from untrusted sources (community registries) are scanned for imperative instructions, URLs, shell commands, and authority claims. Suspicious content is rejected or flagged.

FieldTypeRequiredDescription
<tool_name>objectnoMap of hint key-value pairs for a specific tool
# Example: coverage and hints
backend:
name: github
coverage:
endpoints: 24
total_endpoints: 900
percentage: 3
focus: "repos, issues, PRs, commits, search, releases, actions"
missing: "git primitives, projects v2, teams, webhooks, code scanning"
last_reviewed: "2026-03-26"
hints:
list_project_tasks:
position_type: float64
requires: "call list_views first to get view_id"
kanban_note: "kanban views return buckets with nested tasks, not a flat list"

Human-readable instructions for setting up this DADL backend. Intended for operators, not LLMs. Powers the toolmesh setup <name> CLI command that guides users through credential creation and configuration.

FieldTypeRequiredDescription
credential_stepsarray of stringnoStep-by-step instructions to obtain the required credential (API key, PAT, etc.)
env_varstringnoName of the environment variable to set in .env
backends_yamlstringnoExample backends.yaml entry (multiline YAML string)
required_scopesarray of stringnoAPI scopes or permissions needed for full functionality
optional_scopesarray of stringnoAdditional scopes for extended features (read-only alternatives, etc.)
docs_urlstringnoLink to the API provider’s credential/authentication documentation
notesstringnoAdditional setup notes (e.g. self-hosted URL patterns, regional endpoints)
# Example: setup
setup:
credential_steps:
- "Navigate to GitLab → Settings → Access Tokens"
- "Create a token with scope: api (full access) or read_api (read-only)"
- "Copy the token (starts with glpat-)"
env_var: CREDENTIAL_GITLAB_TOKEN
backends_yaml: |
- name: gitlab
transport: rest
dadl: /app/dadl/gitlab.dadl
url: "https://your-gitlab.example.com/api/v4"
required_scopes:
- api
optional_scopes:
- read_api
docs_url: "https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html"
notes: "For self-hosted GitLab, replace the URL with your instance. The token prefix glpat- is for personal access tokens."

Code examples that serve as few-shot prompts for the LLM in Code Mode. Each example demonstrates a multi-step workflow using api.* calls, helping the LLM understand common patterns for this backend.

FieldTypeRequiredDescription
namestringyesShort name for the example (e.g. "Customer onboarding")
descriptionstringyesWhat the example demonstrates
codestringyesJavaScript/TypeScript code using api.* calls. Same sandbox as composite tools.
# examples
examples:
- name: "Customer onboarding"
description: "Create a customer and retrieve their details"
code: |
const customer = await api.create_customer({
name: "Jane Doe"
});
const details = await api.get_customer({ id: customer.id });
return details;

Optional semantic version string for the DADL file. Enables ToolMesh to detect when a newer version is available in the registry.

backend:
name: github
version: "1.2"
# ...

Format: SemVer-compatible shorthand — MAJOR.MINOR or MAJOR.MINOR.PATCH ("1.2", "1.2.1"). Strict Semantic Versioning requires three components; DADL additionally permits the two-component form, which consumers compare as if .0 were appended ("1.2""1.2.0").

Semantics:

ChangeVersion bumpExample
New tools addedMinor1.11.2
Bug fix in transform/paginationPatch1.2.01.2.1
Tool renamed or removed, breaking param changeMajor1.22.0

Registry integration: The DADL registry (dadl.ai) publishes a manifest with the latest version and checksum for each DADL file. ToolMesh compares the local version against the manifest at startup and logs a notice when an upgrade is available. No automatic updates — the operator decides when to upgrade.

When version is omitted: ToolMesh skips the upgrade check for this backend. This is expected for private/local DADL files that are not published to the registry.

Optional backend-level declaration of the cheapest call that verifies the backend is reachable and the configured credential works. Authentication is injected exactly as for regular tools — a passing health check therefore validates connectivity and the credential in one request (an expired token turns into a visible failure here instead of a surprise 401 on the next real call). When health is absent, no check exists and nothing runs — fully backward compatible.

Two forms:

# health — form 1: reference a declared tool
backend:
health:
tool: get_health # existing tool in this file; MUST have no required params
# health — form 2: inline endpoint (when no tool is worth declaring for it)
backend:
health:
method: GET # default: GET
path: /status
expect_status: 200 # optional — default: any 2xx
timeout: 5s # default: 5s
FieldTypeRequiredDescription
toolstringform 1: yesName of a declared tool (not a composite) to use as the check. The tool MUST NOT have required parameters. Mutually exclusive with method/path.
methodstringnoHTTP method (form 2). Default: GET.
pathstringform 2: yesURL path relative to base_url. Must not contain {param} placeholders.
expect_statusintegernoExact expected status code. Default: any 2xx passes.
expect_pathstringnoJSONPath that must exist in the response body (e.g. "$.status").
auth_expires_pathstringnoJSONPath extracting the credential-expiry timestamp from the check response (e.g. GitLab GET /personal_access_tokens/self"$.expires_at"). See auth_expires_at below.
timeoutstringnoRequest timeout. Default: 5s.
exposebooleannoExpose the check as a synthetic health tool in the generated interface. Default: true.

The synthetic health tool. When a check is declared (either form) and expose is not false, ToolMesh adds a tool named health to the generated TypeScript interface. It returns the standardized result — never the raw API response:

health(): Promise<{
ok: boolean; // check passed (status/expect rules)
http_status?: number; // raw HTTP status — absent when no response was received
error_code?: string; // transport failure: dns_error | tls_error | connection_refused | timeout | protocol_error
latency_ms: number;
checked_at: string; // ISO 8601
auth_expires_at?: string; // ISO 8601 — when the backend credential expires, if known
error?: string; // present when ok is false (mapped per Section 8.2 for HTTP errors)
}>

Semantics: api.health() never rejects — every outcome, including transport failures, is delivered as a result object (that is the point: LLM code branches on ok, not on exceptions). When no HTTP response was received, http_status is absent and error_code names the transport failure. expect_path asserts existence of the path in the response body — the value itself is not inspected. expect_path and auth_expires_path are evaluated against the raw response body of the check call, for both forms — a referenced tool’s response pipeline (Section 9) does not apply to the check.

auth_expires_at turns the check from reactive (credential is expired → ok: false) into proactive (credential will expire). It is filled from two sources, in order of precedence:

  1. The check response, when the DADL declares auth_expires_path — the API’s own answer is authoritative. Runtimes SHOULD accept common timestamp formats (RFC 3339 / ISO 8601, Unix epoch) at that path and normalize to ISO 8601.
  2. Deployment metadata — an operator-supplied expiry recorded next to the credential (backends.yaml / credential store), for APIs whose cheap check endpoint does not report it (e.g. a Tailscale API key: fixed 90-day lifetime, expiry known at creation time). Configuration syntax is deployment-specific, not part of the DADL.

When neither source yields a value, the field is absent. Monitoring consumers MAY raise a warning state once auth_expires_at falls within a configurable window (e.g. 7 days) — turning “surprise 401 in October” into a scheduled credential rotation.

The standardized shape is produced by the check layer, not by the API: with form 1, calling the referenced tool directly still returns its raw API response — only the synthetic health tool normalizes. This lets LLM-written code self-diagnose identically across backends (if (!(await api.health()).ok) … — distinguishing “backend or credential broken” from “my parameters are wrong”) without leaking payload internals.

  • Name collision: if the file declares its own tool or composite named health, the declared one wins and no synthetic tool is generated; validators warn. This is safe: the generated interface always shows the declared tool’s real signature and JSDoc, so LLM code is written against what is actually there — and the synthetic tool, where it exists, is marked as such in its JSDoc (“standardized backend health check”). Referencing the declared tool via health.tool: health (with expose: false) still enables the check for setup and monitoring.
  • Discovery: the synthetic tool exists on every backend that declares a check, so it MUST NOT be ranked in tool-discovery indexes — it is always reachable as api.health() and would only add noise.

Consumers:

  • toolmesh setup <name> runs the health check after credential entry and reports success or the mapped error (Section 8.2) — the operator learns immediately whether the token works.
  • ToolMesh MAY run the check at startup and MAY poll it periodically, aggregating results (e.g. a degraded state naming the failing backend) into its own monitoring endpoints. Whether and how often it polls is deployment configuration (backends.yaml), not part of the DADL. It MUST NOT run the check per tool call.

Authoring rules: the health endpoint MUST be side-effect-free (read semantics) and SHOULD be the cheapest such endpoint the API offers (e.g. Stripe GET /balance, GitHub GET /rate_limit) — not a list endpoint returning large payloads. Every DADL whose API offers a suitable endpoint SHOULD declare health.


DADL supports five authentication patterns. Credentials are referenced by logical name and resolved at runtime by ToolMesh’s three-tier Credential Store (Embedded → Infisical → Vault/OpenBao). The LLM never sees credentials.

# auth — bearer
auth:
type: bearer
credential: vault/stripe-secret-key
inject_into: header # default
header_name: Authorization # default
prefix: "Bearer " # default
# auth — basic
auth:
type: basic
username_credential: vault/bitdefender-api-key
password_credential: vault/bitdefender-password # optional, default: ""

ToolMesh builds the Authorization: Basic base64(username:password) header automatically. If password_credential is omitted, an empty password is used — this is common for APIs that use an API key as the username (e.g. Bitdefender GravityZone, many JSON-RPC APIs).

Four flows are supported via the flow field (default: client_credentials). For all of them, ToolMesh caches the access token in memory and renews it lazily: a request that finds the cached token within refresh_before_expiry of its expiry fetches a fresh one first. On a 401 the cache is invalidated and the request retried once with a new token. The LLM never sees tokens — acquisition, refresh, and injection happen entirely inside ToolMesh; tokens MUST NOT appear in logs, audit payloads, or workflow history.

Protocol details common to all flows: token requests and responses follow RFC 6749 (access_token, token_type, expires_in; scopes are space-separated). Client authentication on the token request is controlled by token_auth: post (credentials in the form body — default, matching the implemented behavior) or basic (HTTP Basic per RFC 6749 §2.3.1).

FlowUse caseInteractive consent
client_credentialsMachine-to-machine APIsnone
refresh_token (v0.2)User-delegated APIs, refresh token obtained out-of-bandout-of-band, before deployment
jwt_bearer (v0.2)Service accounts (RFC 7523) — Google Search Console, Workspacenone
authorization_code (v0.2)User-delegated APIs without service-account support — YouTubeonce, via toolmesh setup

client_credentials — machine-to-machine APIs:

# auth — oauth2 (machine-to-machine)
auth:
type: oauth2
flow: client_credentials
token_url: https://api.example.com/oauth/token
client_id_credential: vault/example-client-id
client_secret_credential: vault/example-client-secret
scopes: ["read", "write"]
token_cache_key: example-api-token
refresh_before_expiry: 60s

refresh_token (since v0.2) — user-delegated APIs (Google, Microsoft Graph, …) where a long-lived refresh token is exchanged for short-lived access tokens at runtime. Files using this flow MUST declare spec v0.2:

# auth — oauth2 (user-delegated)
auth:
type: oauth2
flow: refresh_token
token_url: https://oauth2.googleapis.com/token
client_id_credential: vault/google-client-id
client_secret_credential: vault/google-client-secret # optional — omit for public (PKCE) clients
refresh_token_credential: vault/google-refresh-token
refresh_before_expiry: 60s

The interactive consent that produces the refresh token happens once, out-of-band — describe it in the setup section (for Google: OAuth client in production status, consent URL with access_type=offline&prompt=consent). scopes is not sent on this flow; scopes are fixed at consent time (declaring scopes anyway is not an error — the field is simply ignored).

Refresh-token rotation. Providers that issue a new refresh token on every exchange (common for public clients and modern OAuth security profiles) are supported via the declaration rotates_refresh_token: true. Files using a rotating provider MUST declare it — and MUST list the feature identifier refresh_token_rotation in requires.features (Section 15.3), because a runtime that silently ignored the declaration would lose the credential after the first refresh. Normative runtime behavior when the declaration is present:

  • a newly issued refresh token replaces the stored one atomically; when the response carries no new refresh token, the stored one is kept;
  • persistence failure fails the refresh (fail-closed) — the runtime MUST NOT continue with a possibly-invalidated old token;
  • concurrent refreshes for the same credential are serialized or resolved by compare-and-swap;
  • a runtime whose credential store cannot write (e.g. an environment-variable store) MUST refuse to load the file.

Without the declaration, the stored refresh token is treated as stable (Google does not rotate by default).

jwt_bearer (since v0.2) — service-account APIs per RFC 7523: ToolMesh builds an RS256-signed JWT from a service-account key and exchanges it at the token endpoint for a short-lived access token. Fully headless — no consent screen, no refresh token. This is the preferred flow for Google APIs that support service accounts (Search Console: add the service-account email as a property user; Workspace APIs: domain-wide delegation). Files using this flow MUST declare spec v0.2:

# auth — oauth2 (service account, JWT bearer)
auth:
type: oauth2
flow: jwt_bearer
token_url: https://oauth2.googleapis.com/token
service_account_credential: vault/gsc-service-account
scopes: ["https://www.googleapis.com/auth/webmasters.readonly"]
subject: [email protected] # optional — domain-wide delegation
refresh_before_expiry: 60s

service_account_credential resolves to the complete service-account key (for Google: the JSON key file content with client_email, private_key, token_uri). A declared token_url takes precedence; when absent, the key’s own token_uri is used. ToolMesh signs the assertion (iss = client email, aud = token URL, scope from scopes, exp ≤ 1 hour) and caches the resulting access token like any other flow. The optional subject sets the sub claim to impersonate a user — required for Google Workspace domain-wide delegation, omitted for APIs where the service account acts as itself. (RFC 7523 note: the RFC itself requires a sub claim; omitting it for self-acting service accounts follows Google’s token-endpoint profile. Strictly RFC-conforming endpoints expect sub = iss — runtimes SHOULD send that when subject is absent and the endpoint rejects assertions without sub.)

authorization_code (since v0.2) — three-legged OAuth for user-delegated APIs that do not support service accounts (e.g. YouTube). Unlike flow: refresh_token, where the refresh token is obtained out-of-band, this flow declares the full consent configuration so ToolMesh can drive it: toolmesh setup <name> (or the identity plugin) opens authorize_url in a browser, receives the authorization code on a local callback, exchanges it at token_url, and persists the refresh token in the credential store under refresh_token_credential. At runtime the flow then behaves exactly like refresh_token — silent renewal, no user interaction. Files using this flow MUST declare spec v0.2:

# auth — oauth2 (three-legged, consent driven by toolmesh setup)
auth:
type: oauth2
flow: authorization_code
authorize_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
client_id_credential: vault/youtube-client-id
client_secret_credential: vault/youtube-client-secret # optional — omit for public (PKCE) clients
refresh_token_credential: vault/youtube-refresh-token
scopes: ["https://www.googleapis.com/auth/youtube"]
authorization_params: # provider-specific extras, appended to the authorize request
access_type: offline # Google: required for a refresh token
prompt: consent
refresh_before_expiry: 60s

scopes is sent during consent and fixed afterwards. Provider-specific authorize-request parameters are declared in authorization_params — a plain string map appended to the authorize URL. There is no host-based provider detection; the DADL says what the provider needs (Google: access_type=offline&prompt=consent, without which no refresh token is issued). PKCE: public clients (no client_secret_credential) MUST use PKCE (RFC 7636) with the S256 challenge method — the setup tool generates the verifier, sends the challenge on the authorize request, and the verifier on the token exchange; confidential clients MAY add PKCE on top of the secret. Redirect: by default the setup tool uses a loopback redirect per RFC 8252; providers that require an exact pre-registered URI get it declared via the optional redirect_uri field. Either way, the effective URI must be registered with the OAuth app. Rotation is handled as declared via rotates_refresh_token (see above).

Provider note (belongs in setup): Google OAuth apps in Testing status expire refresh tokens after 7 days — publish the app to In production (or Internal for Workspace) before relying on this flow.

5.4 Session-based (Login → Token → Use)

Section titled “5.4 Session-based (Login → Token → Use)”
# auth — session
auth:
type: session
login:
method: POST
path: /auth/login
body:
username_credential: vault/example-username
password_credential: vault/example-password
extract:
token: "$.data.access_token"
csrf: "$.data.csrf_token"
inject:
- header: Authorization
value: "Bearer {{token}}"
- header: X-CSRF-Token
value: "{{csrf}}"
refresh:
trigger: status_code_401
action: re_login
# auth — apikey
auth:
type: apikey
credential: vault/my-api-key
inject_into: header # header | query
header_name: X-API-Key # when inject_into: header
query_param: api_key # when inject_into: query

The canonical type name is apikey (corrected in v0.2: the v0.1 document spelled it api_key, which the ToolMesh parser has never accepted — published DADL files use apikey). Because the v0.1 text declared api_key valid, consumers MUST accept it: validators and runtimes MUST treat api_key as an alias for apikey. New files SHOULD write apikey.

With inject_into: query, query_param names the query parameter that carries the key (e.g. ?api_key=...); header_name is ignored. Documents declaring v0.2 MUST set query_param when using inject_into: query — an injection without a parameter name cannot work (validators enforce this; the canonical schema leaves the field optional for v0.1 compatibility). With inject_into: header (the default), header_name names the header and query_param is ignored.


Each tool maps to one REST API endpoint. In Code Mode, tools become methods on the auto-generated TypeScript interface that the LLM writes code against.

FieldTypeRequiredDescription
methodstringyesHTTP method: GET, POST, PUT, PATCH, DELETE, HEAD (HEAD implemented since v0.1, documented in v0.2)
pathstringyesURL path (may contain {param} placeholders)
descriptionstringyesUsed as JSDoc comment in TypeScript interface
accessstringnoAccess classification for authorization and policy mapping. See Section 6.4.
paramsobjectnoParameter definitions (path, query, header, body). See Section 6.1.
content_typestringnoRequest content type. Default: application/json (or defaults.content_type when set). Use multipart/form-data for file uploads.
nest_body_keysbooleannoOverrides defaults.nest_body_keys for this tool, in either direction (Section 6.1). Unset inherits the backend default.
max_body_sizestringnoMax upload size, e.g. 50MB
depends_onarraynoInformational: other tools that should be called first. Becomes JSDoc hint.
responseobjectnoResponse transformation config (overrides defaults.response)
paginationstring|objectnonone to disable, or object to override default pagination
errorsobjectnoError mapping (overrides defaults.errors)
returnsstring|objectnoResult type for TypeScript generation — a types name or an inline schema. See Section 6.5.
idempotencyobjectnoIdempotency-key configuration for safe retries of write calls. See Section 6.6.
retry_unsafebooleannoOpt-in: allow automatic retries (Section 8) although the call is not idempotent and declares no idempotency. Default: false.
deprecatedboolean|stringnoMarks the tool as deprecated; a string carries the reason. See Section 6.7.
replaced_bystringnoName of the successor tool in this file. See Section 6.7.

Override semantics (normative since v0.2): a tool-level response, errors, or pagination object replaces the corresponding defaults object as a whole — fields are not merged. A tool that sets only response.result_path therefore drops a default transform; repeat any default fields the tool still needs. One deliberate exception: response.redact is additive — the effective redaction list is the union of defaults.response.redact and the tool’s own redact. A tool-level response block can extend the default redactions but never remove them (Section 9.3); anything else would let an unrelated override silently disable a security control.

All parameters — path, query, and body — are defined under the params key using in: to specify their location. There is no separate body: keyword in DADL. in is REQUIRED for tool parameters (made explicit in v0.2): a parameter without a location cannot be placed in the request and is silently dropped by the runtime — validators reject it.

# params — path, query, and body parameters in one place
params:
id:
type: string
in: path
required: true
limit:
type: integer
in: query
default: 10
description: "Max items to return"
name:
type: string
in: body
required: true
description: "Resource name"
tags:
type: array
in: body
description: "List of tags"
metadata:
type: object
in: body
description: "Arbitrary key-value metadata"

Important: Do NOT use a separate body: block with type: object / properties / required (OpenAPI-style). ToolMesh only exposes parameters defined via params with in: body as tool inputs. A standalone body: block will be silently ignored and the tool will appear with zero parameters.

Supported in: values:

ValueSent as
pathURL path segment (/items/{id})
queryURL query parameter (?limit=10)
headerHTTP request header (e.g. X-Custom-Header: value)
bodyJSON body field (for application/json) or form field (for application/x-www-form-urlencoded)

Dotted body-parameter names (nest_body_keys) (implemented since v0.1, documented in v0.2): by default a dot in an in: body parameter name is part of the literal key — bridge.vlan-filtering is sent as the flat field "bridge.vlan-filtering", which is what APIs with genuinely dotted property names (RouterOS/MikroTik REST) expect. Setting nest_body_keys: true — on defaults for the whole backend, or per tool via the nest_body_keys tool field, which overrides the default in either direction — turns dots into nesting separators instead: gateway.monitor is marshaled as {"gateway": {"monitor": …}} in JSON bodies and as gateway[monitor] in form-urlencoded bodies; names with multiple dots nest recursively. This matches PHP-style model APIs (e.g. OPNsense set_*/add_* endpoints reading nested $_POST trees). Nesting applies to every body field, including declared default: values. Collision rule: when nesting would overwrite an explicitly provided sibling — the tool declares both gateway and gateway.monitor, or an intermediate segment already holds a non-object value — the dotted name is kept as a literal flat key instead, so no provided value is silently dropped.

# nest_body_keys — dotted names become nested body objects
defaults:
content_type: application/x-www-form-urlencoded
nest_body_keys: true
tools:
set_gateway:
method: POST
path: /api/routing/settings/setGateway/{uuid}
description: "Update a gateway (full-replace read-modify-write)"
params:
uuid:
type: string
in: path
required: true
gateway.monitor:
type: string
in: body
description: "Monitor IP for gateway health checks"
# → request body: gateway[monitor]=… (form) / {"gateway": {"monitor": "…"}} (JSON)

Files in DADL are always referenced by URL — never as inline data or local file paths. This keeps tool calls lightweight (only a URL string in the context, not megabytes of Base64) and works with any storage backend (S3, MinIO, NextCloud, ToolMesh’s built-in file broker).

Consumer security requirements (normative since v0.2): fetching caller-supplied URLs is an SSRF surface. A conforming consumer MUST support restricting fetches to an allowlist (or broker-only mode), MUST block private, loopback, and link-local address ranges by default (including after DNS resolution and after each redirect — re-validate the target, guard against DNS rebinding), MUST disable file:// by default (same-host deployments may opt in), and MUST enforce size and content-type limits on fetched files.

When a tool accepts a file, the parameter type is file_url. The caller provides a URL, and ToolMesh fetches the file and builds the appropriate request (e.g. multipart/form-data upload) to the backend API.

Supported URL schemes:

  • https://s3.amazonaws.com/bucket/file.pdf — S3 / MinIO / any HTTP(S) URL
  • tm-blob://9f8a3c1b2e4d5f60718293a4b5c6d7e8 — a blob in ToolMesh’s built-in file broker (section 6.2.3). Resolved by reading the blob store directly — no HTTP fetch, no network reachability requirement.
  • file:///path/on/host — local filesystem (only for same-host deployments, restricted to the allowed upload directory)

HTTP(S) download URLs issued by the file broker (https://toolmesh-host/blobs/<blob-id>) also work, but the tm-blob:// form is preferred: it stays valid even when the caller and ToolMesh cannot reach each other over HTTP.

# file upload tool — URL-based
convert_pdf:
method: POST
path: /api/v1/convert
description: "Convert a PDF to Markdown"
content_type: multipart/form-data
max_body_size: 100MB
params:
file: { type: file_url, in: body, required: true }
title: { type: string, in: body }

6.2.2 File Output (response.type: file_url)

Section titled “6.2.2 File Output (response.type: file_url)”

When a backend returns binary data (PDFs, images, exports), ToolMesh stores the response in its file broker and returns a download URL to the caller. The URL has a configurable TTL and can be shared across sessions.

# binary response → file URL
export_report:
method: GET
path: /reports/{id}/export
description: "Export report as PDF"
params:
id: { type: string, in: path, required: true }
response:
type: file_url
ttl: 24h

ToolMesh provides a built-in file broker for uploading and downloading files outside the MCP channel. Files are stored temporarily with a TTL and referenced by ID. This avoids Base64 overhead in tool calls and enables session-independent file handling.

EndpointMethodDescription
/files/uploadPOSTUpload a file (multipart field file, optional form field ttl as a Go duration like 24h). Requires the same authentication as the MCP endpoint.
/blobs/{blob_id}GET, HEADDownload a blob. Capability URL: the unguessable ID is the only credential, bounded by the TTL.
/blobs/{blob_id}DELETEDelete a blob before its TTL expires. Capability-based like GET: possession of the ID authorizes deletion.

The upload response carries both the handle and the download URL:

{
"file_id": "9f8a3c1b2e4d5f60718293a4b5c6d7e8",
"handle": "tm-blob://9f8a3c1b2e4d5f60718293a4b5c6d7e8",
"url": "https://toolmesh-host/blobs/9f8a3c1b2e4d5f60718293a4b5c6d7e8",
"expires": "2026-07-23T09:00:00Z",
"size": 204800,
"content_type": "image/jpeg"
}

Callers that cannot speak multipart HTTP can use the built-in upload_file MCP tool instead: it takes a URL, fetches it server-side, stores the content as a blob, and returns the same structure — the bytes never pass through the model context.

Large binary values must never travel through the model context: an LLM cannot reproduce a 20 KB Base64 string verbatim in a tool call. Blob handles make the reference the payload — ToolMesh materializes the bytes server-side when it builds the backend request.

A blob handle is tm-blob://<blob-id>, optionally followed by a format fragment. Handles are accepted in two places:

  1. file_url parameters (section 6.2.1): the bare handle resolves to the blob’s content, exactly like an HTTP URL — streamed as raw body or multipart part depending on the tool’s content_type. No fragment needed.

  2. Any other string parameter, at any nesting depth inside object/array parameters: when the entire parameter value is a handle carrying an explicit format fragment, ToolMesh replaces it before the request is built:

FragmentSubstituted valueTypical use
#base64Raw Base64 of the blob content (no prefix)Anthropic source.data image blocks
#dataurldata:<content-type>;base64,<data>OpenAI image_url
#urlThe blob’s HTTP download URLAPIs that fetch from a URL themselves
// Caller-side tool call — the model only ever handles the reference:
{
"input": [{
"role": "user",
"content": [
{ "type": "input_text", "text": "Transcribe this scan." },
{ "type": "input_image", "image_url": "tm-blob://9f8a3c1b2e4d5f60718293a4b5c6d7e8#dataurl" }
]
}]
}

Substitution rules:

  • Whole-value match only. A handle embedded inside a longer string is left untouched. This keeps substitution predictable and prevents accidental expansion inside free-text fields.
  • A bare handle (no fragment) outside a file_url parameter is an error — the runtime cannot guess the intended encoding. Conversely, file_url parameters take only the bare handle; fragments are rejected there.
  • Unknown, expired, or malformed handles fail the tool call with an error — they are never passed through to the backend as literal strings.
  • Size limits apply. Inline substitution (#base64, #dataurl) is capped well below the general file-fetch ceiling (10 MB by default): Base64 inflates payloads by ~33% and the request body is buffered in memory. #url and file_url parameters stream and carry no such cap — prefer them for large files.
  • Handles are capability references: possession of the ID grants access to the content for the duration of the TTL, matching the semantics of broker download URLs.

Substitution is runtime behavior of the caller-facing tool interface. DADL files declare nothing to enable it, and it works identically for tools invoked directly via MCP and from execute_code.

The file broker deliberately trades some inspectability and isolation for the ability to move binary content without routing it through the model context. Deployments should treat these as conscious properties, not oversights:

  • Blob IDs are capabilities. IDs are unguessable (128-bit random) and time-bounded by the TTL. Possession of an ID grants read access to the content — that is the mechanism that lets a download URL (or a #url handle) be handed to a backend without sharing MCP credentials. GET/HEAD /blobs/{id} are therefore unauthenticated. POST /files/upload and DELETE /blobs/{id} are not capability operations — they require the same authentication as the MCP endpoint (there is no legitimate reason for an unauthenticated party to create or destroy a blob).
  • No per-tenant ownership. Blobs live in one flat namespace with no owner tag. For a single-tenant or single-trust-domain deployment this is fine. A deployment serving mutually distrusting tenants over one broker MUST add and enforce an owner/tenant tag on access, because a leaked ID (via logs, errors, or shared audit) would otherwise cross the tenant boundary.
  • Handles materialize downstream of the pre-execution gate. Substitution and file_url resolution happen while the backend request is being built — after authorization and the request-side (pre-execution) policy gate have inspected the call. Those layers, and the audit trail, therefore see the tm-blob:// handle, not the materialized bytes. This matches how the broker already behaves outbound (a binary response is gated as a URL, not as its bytes). A policy that must inspect content leaving to a backend cannot rely on seeing blob-carried payloads; gate on the handle, the tool, and the access class instead.
  • upload_file is an authenticated, public-only fetcher. It resolves URLs server-side with SSRF protection at the dial layer (private, loopback, link-local, and cloud-metadata addresses are refused, redirects included). It is not an open proxy — every fetch is tied to an authenticated caller in the audit log — but it does let any authenticated caller have ToolMesh briefly re-host public content under ToolMesh’s own origin (download-only: Content-Disposition: attachment + nosniff). Deployments that issue low-trust caller credentials may want to gate it by access class or shorten its default TTL.
# binary & streaming responses
download_report:
method: GET
path: /reports/{id}/pdf
description: "Download report as PDF"
params:
id: { type: string, in: path, required: true }
response:
binary: true
content_type: application/pdf
event_stream:
method: GET
path: /events
description: "Stream real-time events"
response:
streaming: true
stream_handling: collect # collect | skip
max_duration: 30s
max_items: 100

The optional access field classifies each tool by its risk level. This metadata enables policy files and authorization layers (OpenFGA) to group tools into roles without hard-coding tool names. Composites carry the same field with the same semantics (Section 12.3).

DADL defines access per tool. Policy files define roles from access levels. OpenFGA assigns roles to users. This three-layer separation keeps DADL portable while enabling fine-grained authorization at deployment time.

# access classification
tools:
list_repos:
method: GET
path: /repos
access: read
description: "List repositories"
create_repo:
method: POST
path: /user/repos
access: write
description: "Create a repository"
update_branch_protection:
method: PUT
path: /repos/{owner}/{repo}/branches/{branch}/protection
access: admin
description: "Update branch protection rules"
params:
owner: { type: string, in: path, required: true }
repo: { type: string, in: path, required: true }
branch: { type: string, in: path, required: true }
delete_repo:
method: DELETE
path: /repos/{owner}/{repo}
access: dangerous
description: "Delete a repository"
params:
owner: { type: string, in: path, required: true }
repo: { type: string, in: path, required: true }

The following values are well-known and understood by ToolMesh’s built-in policy engine:

ValueTypical UseHTTP Methods
readRead-only operations, listing, searchingGET, HEAD
writeCreate or update resourcesPOST, PUT, PATCH
adminPrivileged operations (permissions, settings, configuration)Any
dangerousDestructive or irreversible operationsDELETE, but also POST/PUT that are irreversible

The access field is not restricted to the well-known values above. DADL authors can use domain-specific values that make sense for their API:

# custom access values
tools:
list_invoices:
access: billing
export_user_data:
access: pii
trigger_deploy:
access: ops
send_notification:
access: messaging

Custom values are passed through to policy files and OpenFGA unchanged. ToolMesh does not validate or reject unknown access values — they are treated as opaque strings.

When access is omitted, ToolMesh does not infer a default. Tools without an access field are unrestricted by access-based policies (they can still be restricted by explicit per-tool OpenFGA rules). This is intentional: inferring read from GET would be wrong for endpoints like POST /search or GET /admin/reset-cache.

Best practice: Always set access explicitly. It costs one line per tool and makes the DADL file self-documenting for authorization purposes.

Without openapi_source, generated TypeScript methods return Promise<any> — the LLM has to guess the result shape. The optional returns field types the result:

# returns — typed results without openapi_source
types:
Customer:
type: object
properties:
id: { type: string }
email: { type: string }
name: { type: string }
required: [id]
tools:
get_customer:
method: GET
path: /customers/{id}
access: read
description: "Retrieve a single customer"
returns: Customer
params:
id: { type: string, in: path, required: true }
list_customers:
method: GET
path: /customers
access: read
description: "List customers"
returns:
type: array
items: Customer

Two forms are accepted:

  • String — the name of a type defined in types (Section 10). The generated signature becomes Promise<Customer>.
  • Object — an inline schema using the same JSON Schema subset as Section 10, plus one DADL extension: a bare type name (string) may stand in any type position (returns itself, items, a property value) and refers to a types entry. This shorthand is DADL-specific — it is not JSON Schema. $ref, by contrast, keeps its JSON Schema meaning and takes a pointer: $ref: "#/backend/types/Customer". Do not put a bare name into $ref.

Type names match ^[A-Za-z_][A-Za-z0-9_]*$ (usable as TypeScript identifiers). Validators MUST reject a bare-name or $ref reference that does not resolve to a declared type (Section 15.2).

Semantics: returns describes the value after the response pipeline (result_path, transform, Section 9) has run — the shape the Code Mode caller actually receives, not the raw API body. It is used for TypeScript generation and documentation only; ToolMesh does NOT validate responses against it at runtime. When openapi_source is present, returns overrides the derived type — useful when a transform changes the shape the OpenAPI spec describes.

6.6 Idempotency (idempotency) (since v0.2)

Section titled “6.6 Idempotency (idempotency) (since v0.2)”

Retries of write calls are dangerous: a POST /charges that times out after the server processed it creates a duplicate charge when retried. ToolMesh executes tool calls as Temporal Activities with automatic retries, so writes need protection. Many APIs support an idempotency-key header (the Stripe pattern): requests carrying the same key are executed once, subsequent deliveries return the recorded response.

create_charge:
method: POST
path: /charges
access: write
description: "Create a charge"
idempotency:
header: Idempotency-Key # required — header name the API expects
generate: uuid_v4 # default — the only defined generator in v0.2
FieldTypeRequiredDescription
headerstringyesHeader name the API expects (e.g. Idempotency-Key, X-Request-Id).
generatestringnoKey generator. uuid_v4 (default) is the only value defined in v0.2; further generators are reserved.

Semantics: ToolMesh generates the key before the first attempt of a logical tool call and persists it as part of the durable Activity input (workflow history) — every retry of that call, including after a worker crash or process restart, replays the same key. Two distinct tool calls always get distinct keys. The header is managed by ToolMesh; callers cannot override it, and the declared header name MUST NOT collide — case-insensitively, as HTTP header names compare — with a params entry of in: header or a defaults.headers key (Section 15.2).

Best practice: declare idempotency on every POST tool whose API supports it. GET/PUT/DELETE are typically idempotent by design and do not need it.

6.7 Deprecation & Replacement (deprecated, replaced_by) (since v0.2)

Section titled “6.7 Deprecation & Replacement (deprecated, replaced_by) (since v0.2)”

Tools evolve. Removing or renaming a tool is a breaking change requiring a major version bump (Section 4.5) — and it silently breaks recorded Code Mode workflows and composites that call the old name. deprecated and replaced_by provide the migration path:

list_repos_v1:
method: GET
path: /repos
access: read
description: "List repositories (unpaginated)"
deprecated: "unpaginated — fails on accounts with >1000 repos"
replaced_by: list_repos
  • deprecated: true (or a string carrying the reason) keeps the tool fully functional but marks it @deprecated in the generated TypeScript interface. The LLM sees the JSDoc tag — including the reason string — and prefers the successor.
  • replaced_by names the successor tool in the same file; validators MUST reject a replaced_by value that does not match an existing tool or composite. It renders as “use list_repos instead” in the JSDoc.

Migration path for breaking changes: instead of removing a tool in one step, deprecate it in a minor release (1.2: old tool deprecated + replaced_by, new tool added) and remove it in the next major release (2.0). Registries SHOULD reject a new version that removes a tool which was not deprecated in a previously published version.


Pagination config is adapted from the Airbyte Low-Code CDK, battle-tested across 400+ connectors. Set it in defaults.pagination to apply to all list endpoints, or override per tool.

StrategyDescription
cursorCursor-based (Stripe, Slack). Uses a token from the response to fetch the next page.
offsetOffset-based. Increments an offset parameter.
pagePage number-based. Increments a page parameter.
link_headerRFC 8288 Link header (GitHub). Follows the next relation.
# pagination — cursor example
pagination:
strategy: cursor
request:
cursor_param: after
limit_param: per_page
limit_default: 50
response:
next_cursor: "$.meta.next_cursor"
has_more: "$.meta.has_more"
behavior: auto # auto | expose
max_pages: 10 # safety limit

When behavior is auto, ToolMesh fetches all pages transparently. When expose, the LLM controls pagination via the cursor parameter in Code Mode: ToolMesh injects the paging parameter (named by request.cursor_param / page_param / offset_param, matching the strategy) into the generated TypeScript interface from the pagination config — declaring it in params is OPTIONAL and only useful to customize its description. For page-strategy APIs that report the page count in a header, response.total_pages_header names it.


Error mapping triggers on non-2xx responses. 2xx bodies always flow through the response pipeline (Section 9) — APIs that embed error indicators in 200 responses cannot be mapped here. Redirects are followed by the HTTP transport; a 3xx that still surfaces (redirect loop, limit reached) enters error mapping like any other status. A status listed in neither retry_on nor terminal is treated as terminal (no retry); a status MUST NOT appear in both lists (Section 15.2). The single automatic re-authentication retry on 401 (Sections 5.3/5.4) happens below error mapping and is not affected by terminal: [401] — it is safe for every method, because a 401 means the server rejected the request before executing it.

Retry safety (normative since v0.2): an automatic retry re-executes the request — after a timeout or 5xx, the provider may already have performed the operation. Consumers MUST therefore apply retry_on (and the rate-limit retries of Section 8.1) only when at least one of the following holds:

  • the method is idempotent by HTTP semantics (GET, HEAD, PUT, DELETE),
  • the tool declares idempotency (Section 6.6) — the reused key makes re-execution safe,
  • the tool opts in explicitly with retry_unsafe: true (the author accepts duplicate execution).

A POST or PATCH without idempotency and without retry_unsafe fails on the first retryable error instead of being retried.

# defaults.errors
errors:
format: json
message_path: "$.error.message"
code_path: "$.error.code"
retry_on: [429, 502, 503, 504]
retry_strategy:
max_retries: 3
backoff: exponential
initial_delay: 1s
terminal: [400, 401, 403, 404, 409]
rate_limit:
header: X-RateLimit-Remaining
retry_after_header: Retry-After
map: # since v0.2 — see Section 8.2
404: not_found
409: conflict
429: rate_limited

format declares how error bodies are parsed: json (the fully specified value — message_path/code_path apply), text (the whole body becomes the message; the paths are ignored), or xml (reserved — parsing behavior is implementation-defined in v0.2). retry_strategy.backoff: exponential is the defined strategy; other values are implementation-defined.

When rate_limit is configured, ToolMesh performs proactive throttling — it inspects rate-limit headers on every response and acts before the API rejects requests.

Request flow:

  1. Before each request, ToolMesh checks the cached value of rate_limit.header (e.g. X-RateLimit-Remaining).
  2. If the remaining count is 0, ToolMesh pauses the request and waits until the reset time.
  3. The wait duration is determined by (in order of precedence):
    • The retry_after_header response header (e.g. Retry-After: 30) — seconds or HTTP date.
    • The X-RateLimit-Reset header if present — Unix timestamp.
    • Fallback: exponential backoff starting at retry_strategy.initial_delay.
  4. After waiting, ToolMesh sends the request. Proactive waiting happens before anything was sent — it is not a re-execution and is exempt from the Section 8 retry-safety rule; the wait still counts toward retry_strategy.max_retries as a budget.
  5. If a 429 response arrives despite proactive throttling (race condition, shared quota), it is handled by retry_on with the same backoff strategy — this is a re-execution, so the Section 8 retry-safety rule applies.

When rate_limit is not configured: ToolMesh relies solely on retry_on — a 429 response triggers reactive retries with the configured backoff strategy. No proactive throttling occurs.

FieldTypeDescription
headerstringResponse header containing remaining request quota (e.g. X-RateLimit-Remaining)
retry_after_headerstringResponse header indicating when to retry (e.g. Retry-After). Supports seconds and HTTP date formats.

8.2 Semantic Error Codes (errors.map) (since v0.2)

Section titled “8.2 Semantic Error Codes (errors.map) (since v0.2)”

HTTP status codes are transport details; Code Mode error handling should branch on stable, API-independent values instead of parsing status numbers and message strings. errors.map maps HTTP status codes to semantic error codes:

errors:
map:
400: not_found # this API returns 400 for missing resources
409: conflict
422: invalid_input
429: rate_limited

Error object in Code Mode: a failed call rejects with an error carrying:

FieldSource
codeSemantic code from errors.map (falling back to the default mapping below)
http_statusRaw HTTP status code
messageExtracted via errors.message_path
provider_codeExtracted via errors.code_path (the API’s own error code, e.g. Stripe’s resource_missing)

This lets composites and LLM-written code branch reliably:

try {
return await api.get_customer({ id });
} catch (e) {
if (e.code === "not_found") return null; // expected — customer may not exist
throw e; // everything else propagates
}

Well-known codes and default mapping. When map is absent or does not cover a status, ToolMesh applies these defaults:

CodeDefault HTTP status
invalid_input400, 422
unauthorized401
forbidden403
not_found404, 410
conflict409
timeout408
rate_limited429
internal500
unavailable502, 503, 504
client_errorany other 4xx
server_errorany other 5xx
unexpected_statusanything else that surfaces (e.g. an unresolved 3xx)

The last three are catch-alls — every non-2xx status maps to some semantic code; e.code is never absent.

map overrides the defaults selectively — declare it only for statuses the API uses in a non-standard way (e.g. 400 for missing resources). Only 4xx/5xx statuses can be mapped; 2xx responses never enter error mapping (see the trigger rule above). Note for validation: YAML integer keys (404:) are stringified ("404") when a document is checked against the canonical JSON Schema. Like access, the code values are not restricted: custom codes (e.g. insufficient_funds) are passed through as opaque strings, but the well-known codes above SHOULD be preferred so error-handling code stays portable across backends.


Most APIs wrap results in container objects. Response transformation extracts the relevant data before it reaches the LLM, reducing token consumption. This is critical for IoT and status APIs that return large payloads with system internals (RAM, firmware, WiFi details) that are irrelevant for the LLM.

# defaults.response — applies to all tools unless overridden
response:
result_path: "$.data" # JSONPath to the actual result
metadata_path: "$.meta" # extracted separately (for pagination, not sent to LLM)
transform: | # optional jq filter — runs on the result_path extraction
map({id, name, status})
max_items: 100
allow_jq_override: true # LLM can pass ad-hoc jq filters

Individual tools can override defaults.response to apply custom transformations. This is especially useful when a single API returns large, deeply nested payloads that should be flattened or filtered before reaching the LLM context.

# tool-level response override — reduces a 60KB IoT status payload to ~2KB
get_all_device_status:
method: POST
path: /device/all_status
description: "Get status of all devices"
response:
result_path: "$.data.devices_status"
transform: |
to_entries | map({
id: .key,
name: (.value._dev_info.name // .key),
online: (.value._dev_info.online // false),
relay_on: [.value.relays // [] | .[] | select(.ison)] | length > 0,
switch_on: (.value."switch:0".output // false),
power_w: (.value."switch:0".apower // 0)
})
FieldTypeDescription
result_pathstringJSONPath to extract before transform runs. Applied first.
metadata_pathstringJSONPath to pagination/meta info (not sent to LLM).
transformstringjq filter applied after result_path extraction. Use to flatten, rename, or filter fields.
max_itemsintegerTruncate arrays to this length (prevents context overflow).
allow_jq_overridebooleanWhen true, the LLM can pass ad-hoc jq filters at call time. Consumers MUST run such filters under resource limits: CPU/wall-clock time, memory, maximum serialized output size, and recursion depth — an ad-hoc filter can burn resources even though it cannot unmask redacted data.
redactarray of stringJSONPaths whose values are masked before the response leaves ToolMesh. (since v0.2 — see Section 9.3)

Best practice: Always add response.transform to status/list endpoints that return more than ~5KB per item. LLM context is expensive — strip firmware versions, MAC addresses, WiFi RSSI, uptime counters, and other system internals unless they are the primary purpose of the tool.

9.3 Redaction (response.redact) (since v0.2)

Section titled “9.3 Redaction (response.redact) (since v0.2)”

Some API responses embed secrets that the caller has no business seeing: webhook configurations with signing secrets, user objects with API keys, SMTP settings with passwords. response.redact masks them declaratively:

# redact — mask embedded secrets before the LLM sees them
list_webhooks:
method: GET
path: /webhooks
access: read
description: "List configured webhooks"
response:
result_path: "$.data"
redact:
- "$[*].secret"
- "$[*].auth.password"

Semantics:

  • Each entry is a JSONPath evaluated against the response; every matched value is replaced with the string "[REDACTED]". Paths that match nothing are a no-op, not an error.
  • Pipeline order: result_pathtransformredact → ad-hoc jq override (if allowed) → max_items. Paths are therefore relative to the transformed result, and an allow_jq_override filter supplied at call time operates on already-redacted data — the override cannot be used to exfiltrate masked values.
  • Redaction cannot be disabled by the caller. It applies to Code Mode results, composite-internal api.* calls, and audit-log payloads alike.
  • Unlike the rest of the response object, redact merges additively across levels: a tool-level response block extends defaults.response.redact but can never remove a default redaction (Section 6, override semantics).

Relation to the Output Gate: the Output Gate applies deployment-specific policies (PII rules, caller-dependent filtering) configured by the operator. response.redact complements it from the other side: the DADL author knows where this particular API leaks secrets and encodes that knowledge portably in the file itself. Defense in depth — both layers run.

Scope: redaction operates on the response pipeline only. Error responses (Section 8) never enter it — their caller-visible surface is limited to the extracted message and provider_code fields, not the raw error body.

Every field that takes a JSONPath expression — result_path, metadata_path, redact, errors.message_path / code_path, pagination.response.next_cursor / has_more, health.expect_path / auth_expires_path, and session extract — uses RFC 9535 syntax and semantics, restricted to this subset:

ConstructExampleSupport
Root + name selectors (dot notation)$.data.itemsREQUIRED
Index selector, including negative$.data[-1].idREQUIRED
Wildcard selector$[*].secretREQUIRED
Descendant segments, slices, filters$..id, $[1:3], $[?(...)]Not part of the dialect — authors MUST NOT use them

All three selector kinds are REQUIRED in every JSONPath field — a conforming consumer supports the same dialect everywhere, so a valid document behaves identically across consumers. Validators MUST reject paths outside the dialect (Section 15.2).

A consumer that encounters a construct it does not implement MUST fail the call (or reject the file at load time) rather than silently returning nothing — for redact, a non-matching path is a no-op only when the path is valid and simply absent from the data, never because the engine could not parse it.


When openapi_source is provided, types are derived from the OpenAPI spec. Without it, you can define types inline using a JSON Schema subset. These are used to generate TypeScript interfaces for Code Mode. Tools reference them by name via returns (Section 6.5).

# types — inline definitions
types:
Customer:
type: object
properties:
id: { type: string }
email: { type: string }
name: { type: string }
metadata:
type: object
additionalProperties: { type: string }
required: [id]

Supported JSON Schema keywords (for TypeScript generation):

type, properties, items, required, $ref, enum, description, additionalProperties, oneOf, anyOf, allOf.

Validation keywords (minLength, pattern, minimum, etc.) are accepted but not used for TypeScript generation. This allows copy-paste from OpenAPI schemas without modification.


DADL supports two levels of reuse: standard YAML anchors (intra-file) and DADL includes (cross-file). There is no templating, no inheritance, no conditionals.

# YAML anchors — native DRY
# Underscore-prefixed keys are ignored by ToolMesh
_defaults:
pagination: &default-pagination
strategy: cursor
request:
cursor_param: starting_after
limit_param: limit
limit_default: 50
behavior: auto
max_pages: 20
backend:
defaults:
pagination: *default-pagination # alias replaces the whole node

An alias (*name) substitutes the entire anchored node. Variants need their own anchors — there is no partial override via anchors.

Do not use YAML merge keys (<<). (clarified in v0.2) Merge keys look like partial override but merge shallowly: a nested map in the overriding block replaces the anchored map entirely, silently dropping its other fields (request: { cursor_param: x } would lose limit_param and limit_default). The feature was also dropped from YAML 1.2, and the public DADL registry rejects files containing <<. Authors SHOULD NOT use merge keys; use whole-node anchors or spell the variant out.

# includes
includes:
- path: common/oauth2-client-credentials.dadl.yaml
merge_into: backend.auth
overrides:
token_url: https://api.stripe.com/oauth/token
client_id_credential: vault/stripe-client-id
- path: common/standard-rest-errors.dadl.yaml
merge_into: backend.defaults.errors

Include fragments are files with _fragment: true at the top level. Merge semantics: deep merge, overrides win. Arrays are replaced, not appended. Includes are flat — no nested includes (max 1 level).

Fragments are not standalone DADL documents: they carry _fragment: true instead of spec/backend and do not validate against the canonical schema on their own. Document conformance (Section 15.2) is evaluated on the composed result after includes are merged. The .dadl.yaml extension distinguishes fragments from loadable .dadl files.


Composite tools are server-side TypeScript functions that combine multiple primitive tools into a single, higher-level operation. They solve problems that response.transform (jq) cannot: cross-endpoint joins, multi-step workflows, and business logic that requires branching or loops.

ProblemSolution
Single endpoint has too much dataresponse.transform (jq)
Join data from two endpoints (e.g. names + status)Composite tool
Multi-step workflow (create → configure → verify)Composite tool
Conditional logic (if device is X, call Y)Composite tool

Composites are defined under the composites key at the same level as tools. They appear as regular tools in the TypeScript interface — callers cannot distinguish them from primitive tools.

composites:
get_named_status:
description: "Get all device status with human-readable names and on/off state"
access: read
params:
only_on:
type: boolean
default: false
description: "If true, return only devices that are currently on"
timeout: 30s
code: |
const devices = await api.list_devices();
const nameMap = Object.fromEntries(devices.map(d => [d.id, d.name]));
const status = await api.get_all_device_status();
const result = status.map(d => ({
...d,
name: nameMap[d.id] || d.id
}));
if (params.only_on) {
return result.filter(d => d.relay_on || d.switch_on);
}
return result;
FieldTypeRequiredDescription
descriptionstringyesUsed as JSDoc comment in TypeScript interface
accessstringnoAccess classification, same values and policy mapping as for tools (Section 6.4). (since v0.2)
delegatesarraynoInner tools this composite intends to call under its own authority instead of the caller’s — effective only with deployment-policy approval. See Authorization below. (since v0.2)
paramsobjectnoInput parameters (same syntax as tool params, but in: is not used)
codestringyesTypeScript/JavaScript function body. Has access to api.* (all tools in this backend) and params (input parameters).
timeoutstringnoMax execution time (default: 30s). Killed after timeout.
depends_onarraynoInformational: primitive tools called internally.

Authorization (since v0.2): the policy layer treats a composite exactly like a tool — its access value feeds the same role mapping. For its inner calls, the default is fail-closed: every inner api.* call is additionally checked against the caller’s per-tool permissions. A caller cannot reach anything through a composite that it could not call directly — a DADL file, including one installed from a registry, can never widen privileges by itself.

Deliberate encapsulation (hard-wired parameters turning a broad write primitive into one specific, safe operation that e.g. read users should be able to invoke) uses a two-key mechanism:

  1. The composite declares which inner tools it intends to run under its own authority: delegates: [delete_item]. The declaration alone changes nothing; validators check that every entry names an existing tool (Section 15.2), registries and audits can flag it.
  2. The deployment policy approves the delegation (per backend or per composite). Only then do the listed inner calls skip the caller re-check and run under the composite’s authority.

Runtimes MUST NOT honor delegates without deployment approval — the author proposes, the operator decides, mirroring the access → policy-mapping split. Inner calls are audited individually in every mode (Section 12.4). access SHOULD reflect what the composite does from the caller’s perspective; with approved delegation the narrower value is exactly the point.

Composite code runs in a restricted sandbox with the following constraints:

AllowedForbidden
api.* calls (tools in the same backend)fetch(), XMLHttpRequest, any network I/O
params (input parameters)require(), import, dynamic module loading
Pure JS: map, filter, reduce, JSON.*, Math.*, Date.*fs, process, child_process, os
console.log (captured to audit log)eval(), Function(), globalThis mutation
await (for api.* calls)Accessing other backends or services
String/Array/Object manipulationsetTimeout, setInterval (use timeout field instead)

Additional runtime constraints:

  • Timeout: Hard-killed after the configured timeout (default 30s, max 120s).
  • Call depth: Composites can only call primitive tools, not other composites. Max 50 api.* calls per execution.
  • No side-channel: Composites cannot construct URLs or make HTTP calls outside of api.*. All network access is mediated by ToolMesh.
  • Audit: Every api.* call within a composite is logged individually in the audit trail with the composite’s name as parent context.

Security note: When DADL files contain composites, ToolMesh sets contains_code: true in the backend metadata. Deployment pipelines should flag DADL files with composites for automated static analysis (AST scanning for forbidden globals, network calls, eval patterns). Manual review is not scalable — automated scanning at CI/CD time is the primary gate. See the ToolMesh Security Guide for reference AST rules.

  • Keep composites short (< 30 lines). If it is longer, the logic probably belongs in a dedicated microservice.
  • Use composites for read-only joins and aggregations. Avoid composites that write to multiple endpoints — use Temporal workflows for durable multi-step mutations.
  • Always set a description that explains what the composite does, not how. The LLM sees this in the TypeScript interface.
  • Prefer response.transform (jq) when a single endpoint is involved. Composites are for multi-endpoint orchestration.

# stripe.dadl
spec: "https://dadl.ai/spec/dadl-spec-v0.2.md"
requires:
features: [idempotency] # load-bearing — must not degrade silently (Section 15.3)
backend:
name: stripe
type: rest
version: "1.0"
base_url: https://api.stripe.com/v1
description: "Stripe payment processing API"
openapi_source: https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml
auth:
type: bearer
credential: vault/stripe-secret-key
health:
method: GET
path: /balance
timeout: 5s
defaults:
content_type: application/x-www-form-urlencoded
pagination:
strategy: cursor
request:
cursor_param: starting_after
limit_param: limit
limit_default: 100
response:
next_cursor: "$.data[-1].id"
has_more: "$.has_more"
behavior: expose
max_pages: 10
errors:
format: json
message_path: "$.error.message"
code_path: "$.error.type"
retry_on: [429, 502, 503]
map:
402: card_declined
404: not_found
response:
result_path: "$.data"
allow_jq_override: true
tools:
list_customers:
method: GET
path: /customers
access: read
description: "List all customers"
params:
email: { type: string, in: query, required: false }
limit: { type: integer, in: query, default: 10 }
get_customer:
method: GET
path: /customers/{id}
access: read
description: "Retrieve a single customer by ID"
params:
id: { type: string, in: path, required: true }
response:
result_path: "$"
pagination: none
create_customer:
method: POST
path: /customers
access: write
description: "Create a new customer"
idempotency:
header: Idempotency-Key
params:
email: { type: string, in: body, required: true }
name: { type: string, in: body }
metadata: { type: object, in: body }
response:
result_path: "$"
pagination: none
examples:
- name: "Customer onboarding"
description: "Create a customer and retrieve their details"
code: |
const customer = await api.create_customer({
name: "Jane Doe"
});
const details = await api.get_customer({ id: customer.id });
return details;

DADL files are consumed by ToolMesh and integrated into its six-pillar architecture:

PillarDADL Integration
Code ModeTypeScript interfaces are auto-generated from DADL tools and types. The LLM writes code against api.* methods.
TemporalEach execute() call runs as a Temporal Activity — retry, timeout, and full audit trail.
OpenFGAPer-tool authorization. The access field on each tool enables role-based policy mapping (e.g. read → reader role). Policies can further restrict by user, plan, or caller origin.
MCP AggregationDADL backends mix seamlessly with native MCP backends in the same ToolMesh instance.
Credential Storecredential: vault/xxx references are resolved through the three-tier store (Embedded → Infisical → Vault/OpenBao).
Output GateResponses pass through goja-based policies (PII redaction, rate limiting, caller-dependent filtering).

The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.

15.2 Canonical JSON Schema & Document Conformance

Section titled “15.2 Canonical JSON Schema & Document Conformance”

The canonical, machine-readable schema for this version is published at:

https://dadl.ai/schema/v0.2.json

(source of truth: docs/schema/dadl-v0.2.schema.json in the ToolMesh repository). It is the shared foundation for linters, the dadl validate CLI, and registry CI pipelines — one schema, every validator.

A file is a conforming DADL document when:

  1. it is valid YAML,

  2. it validates against the canonical JSON Schema of the spec version it declares in spec (v0.1 predates this chapter and has no canonical schema of its own — documents declaring v0.1 are validated against the v0.2 schema, which is additive, so every valid v0.1 document passes), and

  3. it satisfies the constraints that JSON Schema cannot express. Machine-checkable — validators MUST enforce:

    • every {param} placeholder in a path has a matching params entry with in: path, and vice versa;
    • replaced_by references an existing tool or composite in the same file;
    • every load-bearing feature the file uses (redact, idempotency, rotates_refresh_token — Section 15.3) is declared in requires.features;
    • a file using any feature marked (since v0.2) declares a v0.2 spec URL;
    • health.tool references an existing tool in the same file, and that tool has no required parameters;
    • every delegates entry references an existing tool in the same file;
    • every returns / bare-name / $ref type reference resolves to a declared type (Section 6.5);
    • every JSONPath expression parses within the Section 9.4 dialect;
    • no status code appears in both retry_on and terminal;
    • a declared idempotency.header does not collide (case-insensitively — HTTP header names) with a params entry of in: header or a defaults.headers key;
    • in documents declaring v0.2: auth.inject_into: query declares query_param;
    • includes are at most one level deep, and include fragments carry _fragment: true.

    Author obligations — not machine-checkable; registries enforce by review:

    • composite code calls only primitive tools of the same backend (statically checkable only in the absence of dynamic access such as api[name]);
    • the health endpoint is side-effect-free.

Document conformance is evaluated after includes are resolved; fragment files themselves (Section 11.2) are exempt.

Validation strictness is context-dependent (see Section 15.3): publish-time validators (registry CI, dadl validate) MUST treat unknown keys as errors; runtime consumers MUST NOT.

The canonical schema is the strict publish-time profile (unknown keys are errors). Runtimes do not consume it with these settings — their warn-and-ignore behavior (Section 15.3) is a different validation profile by design; the two must not be conflated.

Registries MAY impose additional publication requirements beyond document conformance — the public DADL registry, for example, requires credits, source_name, source_url, date, and an explicit access classification on every tool and composite (tools without access are unrestricted by access-based policies, Section 6.4 — an acceptable default for private files, not for published ones).

15.3 Forward Compatibility: Unknown Keys & requires

Section titled “15.3 Forward Compatibility: Unknown Keys & requires”

DADL files and DADL consumers evolve independently — a file written against a newer spec revision will meet older runtimes. Three rules keep that safe:

Unknown-key policy:

ContextUnknown key handling
Publish-time validation (registry CI, dadl validate, linters)MUST reject — catches typos and unspecified fields before they spread
Runtime consumers (ToolMesh)MUST warn and ignore — a file using only additive newer features keeps working, degraded but visibly
Underscore-prefixed keys (_*)Ignored silently by every consumer, validators and runtimes alike (YAML anchor workspace). The canonical schema permits them at the document top level.

Unknown-value policy. Keys can be ignored; values of a key the consumer does implement cannot. For behavior-determining enum fieldsbackend.type, auth.type, auth.flow, pagination.strategy, pagination.behavior, idempotency.generate, response.stream_handling — a consumer that does not implement the declared value MUST reject the file (fail-closed) rather than guess, substitute a default, or call the API with wrong semantics. Fields defined as opaque pass-through strings (access, errors.map codes) are exempt.

requires — declared hard requirements. Warn-and-ignore is wrong when a feature is load-bearing: a runtime that ignored an unknown response.redact would silently expose the very secrets the author masked. When a file depends on a feature for correctness or security, it MUST declare it:

# top level, next to spec:
requires:
toolmesh: ">=0.9.0" # semver range — minimum runtime version
features: [redact, jwt_bearer]

A runtime that cannot satisfy every entry in requires MUST refuse to load the file (fail-closed) with a message naming the missing capability. toolmesh takes a semver range using comparison operators >=, >, <=, <, = with comma-separated AND (e.g. ">=0.9.0, <2.0.0"); features takes feature identifiers defined by spec releases. Prefer features over toolmesh: it names the capability portably instead of one implementation’s version number — use the version range only for implementation-specific needs (e.g. a runtime bug fixed in a given release). v0.2 defines:

Feature identifierSection
refresh_token5.3
jwt_bearer5.3
authorization_code5.3
health4.6
returns6.5
idempotency6.6
deprecation6.7
semantic_errors8.2
redact9.3
refresh_token_rotation5.3
composites12
file_url6.2

Authoring rule: files MUST declare requires.features for every feature whose silent absence would change semantics dangerously — redact, idempotency, and refresh_token_rotation always; returns or deprecation (documentation-only) need not be declared. Validators enforce this mechanically (feature used ⟹ feature declared). The OAuth flows themselves need no requires entry: they are covered by the unknown-value policy (auth.flow is behavior-determining) plus the mandatory v0.2 spec URL.

Bootstrap limitation. The fail-closed guarantee of requires binds only consumers that implement requires itself (spec v0.2 and later). A consumer predating it sees an unknown top-level key and — under its own policy — ignores it; the spec: URL is the only signal such a consumer can act on. This is inherent to introducing the mechanism and is why a consumer SHOULD warn whenever it loads a file declaring a spec version newer than the one it implements (Section 15.4).

The design rationale is recorded in ADR-0003 (DADL Spec Versioning & Forward Compatibility) in the ToolMesh repository.

Consumer conformance comes in three profiles, so “supports DADL v0.2” always has a precise meaning:

Profile 1 — Document Validator (registry CI, dadl validate, linters): implements the canonical schema plus every machine-checkable constraint of Section 15.2, with strict unknown-key handling. Makes no claims about execution.

Profile 2 — Core Runtime: executes DADL files and MUST, without exception:

  • implement the unknown-key policy (warn and ignore) and honor requires fail-closed;
  • reject files declaring an unsupported value of a behavior-determining enum (backend.type, auth.type, auth.flow, pagination.strategy, pagination.behavior, idempotency.generate, response.stream_handling) rather than guessing — never call the API unauthenticated or with wrong semantics;
  • accept api_key as an alias for apikey;
  • resolve credentials outside the LLM context — credential values, tokens, and signed assertions MUST NOT appear in tool results, generated interfaces, logs, or workflow history;
  • apply response.redact before any caller-visible output (including ad-hoc jq overrides and audit payloads), enforce the retry-safety rules of Section 8, keep idempotency keys stable across retries, apply the composite authorization default of Section 12.3, and meet the file_url security requirements of Section 6.2 for the features it implements;
  • implement the Section 9.4 JSONPath dialect wherever it accepts JSONPath.

A Core Runtime MAY leave whole features unimplemented — the unknown-value policy covers gaps in enum-declared behavior (an auth flow, a pagination strategy), and requires.features covers optional feature areas (composites, file_url): files depending on such an area SHOULD declare it, turning the gap into a clean load-time rejection instead of silently missing tools. It MAY load files declaring a newer spec version than it implements (best effort, with a warning and per-key warnings) — unless requires says otherwise.

Profile 3 — Full Runtime: a Core Runtime that implements every non-optional semantic this specification defines — all auth types and flows (Section 5), all pagination strategies (Section 7), health checks, composites, and file handling. “Full v0.2 support” claims this profile; anything less names the profile and its gaps (e.g. “Core Runtime; no jwt_bearer, no composites”).


The following area is under active design and explicitly not part of v0.2:

  • Session semantics for LLM backends — a session: block (system prompt, TTL, context-window strategy) and a backend type: llm with provider:/model:, turning stateful conversations with an expert model into a DADL backend. Each session keeps an isolated context; the caller passes in only what it explicitly sends.

These constructs are not part of v0.2, and Section 15.3 already governs what happens when they appear: the session: key is an unknown key (validators reject it, runtimes warn and ignore it), while type: llm is an unknown value of a behavior-determining field — every v0.2 consumer rejects such a file outright.


DADL is created and maintained by Dunkel Cloud GmbH

ToolMesh · GitHub · This specification is licensed under CC BY-SA 4.0. ToolMesh source code is licensed under Apache 2.0.