# `tools` — block authoring reference

Family: **INFRA** (anchor for `intent = data_exchange`) +
**WORKFLOWS** (tagged for `intent ∈ {sms, email, voice, export}`).

A tool is an authenticated connection to an external system that
the platform invokes to **execute actions**. Two orthogonal
dimensions:

- **intent** classifies what the tool DOES (move data, send a
  message, render a report)
- **body type** declares HOW the tool connects (S3, SFTP, SQL,
  REST, AWS Lambda, …) via a polymorphic discriminator

A datalake can own many tools. Some tools nest under a data source
(`data_source_id` populated — typical for ingestion-receiving
tools like `manual_upload`); others are standalone and reachable
to any workflow on the datalake.

## 1. Wire shape

SDK TypeScript type (request shape; server-derived fields excluded):

```
   import type { ToolRequestWritable }
     from '@alvera-ai/platform-sdk'
```

No runtime validator is exported — validation is server-authoritative
(a bad body returns a 422 `AlveraApiError`; see `errors.md`).

Intent enum (typed):

```
   import { ToolIntent } from '@alvera-ai/platform-sdk'

   ToolIntent.DATA_EXCHANGE   // fetch / push payloads
   ToolIntent.SMS             // outbound text messages
   ToolIntent.MMS             // outbound MMS (media) messages
   ToolIntent.EMAIL           // outbound email
   ToolIntent.VOICE           // outbound voice / voicemail
   ToolIntent.EXPORT          // report / extract generation
   ToolIntent.STATUS_POLLER   // polls external systems for
                              //  delivery/action status; paired
                              //  with action status updaters
                              //  (see action_status_updaters.md)
   ToolIntent.LLM_ENRICHMENT  // LLM-backed row enrichment; the tool
                              //  is the complete provider adapter
                              //  (rest_api or aws_lambda body against
                              //  an OpenAI-compatible endpoint) and
                              //  REQUIRES a `response_extractor` that
                              //  maps the raw response into the
                              //  canonical enrichment result. Paired
                              //  with AI agents (see ai_agents.md).
```

`ToolIntent` has exactly these eight members — the wire values are
`sms`, `mms`, `email`, `export`, `voice`, `data_exchange`,
`status_poller`, `llm_enrichment` (`Ecto.Enum` in
`platform/lib/platform/tools/tool.ex` line 241; `enum ToolIntent` in
`packages/sdk/src/generated/types.gen.ts` line 881). There is no
`chat_completion` or `context_extraction` intent.

Polymorphic body dispatch — the `body.tool_body_type` field is
the discriminator. Each branch is its own TypeScript type:

```
   tool_body_type = 'manual_upload'  →  manual-upload receiver
                                        (presigned-URL upload path)
   tool_body_type = 's3'             →  AWS S3 / R2 / custom-endpoint
                                        object storage
   tool_body_type = 'sftp'           →  SFTP file transfer
   tool_body_type = 'sql_database'   →  SQL (Postgres, MySQL, …)
   tool_body_type = 'rest_api'       →  generic REST endpoint
   tool_body_type = 'sns'            →  AWS SNS topic (used for
                                        SMS / push notifications)
   tool_body_type = 'twilio'         →  Twilio Programmable Messaging
                                        (SMS). A first-class sender —
                                        do NOT hand-author a rest_api
                                        body for Twilio; see §2 below
   tool_body_type = 'end_user_messaging'
                                     →  AWS End User Messaging (used
                                        for MMS / media messages via
                                        the SendMediaMessage API)
   tool_body_type = 'sharepoint'     →  Microsoft Graph SharePoint
   tool_body_type = 'aws_lambda'     →  invoke a Lambda function
   tool_body_type = 'sqs'            →  AWS SQS queue
   tool_body_type = 'cloud_watch_log_group'
                                     →  AWS CloudWatch Logs poller
                                        (paired with
                                         ToolIntent.STATUS_POLLER)
   tool_body_type = 'email'          →  dedicated email-provider body
                                        (Mailgun, SendGrid, SES,
                                         SMTP-bridge; optional
                                         endpoint_url: for SES the
                                         whole request URL, for
                                         Mailgun the API base URL)
                                        — alternative to using
                                        rest_api for email intents
```

There is no standalone `tool_body_type` enum export — the
discriminator is a string-literal on each branch of the generated
`ToolBodyWritable` union (`tool_body_type: 's3'`, `'rest_api'`, …).
Consult the branch literals in
`packages/sdk/src/generated/types.gen.ts` for the complete set,
since new body types land independently of doc revisions.

## 2. Rules the type cannot encode

### Twilio is a first-class sender — never hand-author `rest_api` for it

Use `tool_body_type: 'twilio'`. Reaching for `intent: 'sms'` + a
hand-authored `rest_api` body was the old workaround and it is now a
mistake with a consequence: a `rest_api` Twilio sender has no
`status_poller/twilio/…` path, so **the send can never learn what
happened to it** — no delivery status, no reconciliation.

```typescript
import { ToolIntent } from '@alvera-ai/platform-sdk'

await api.tools.create(tenantSlug, datalakeSlug, {
  name: 'Campaign Sender — Main Line',
  description: 'Twilio sender for the campaign workflow.',
  intent: ToolIntent.SMS,
  status: 'active',
  datalake_id: datalakeId,
  // The provider-specific fields are NESTED under `body`, discriminated by
  // `tool_body_type` — they are not top-level keys on the create request.
  // Flattening them is the most common 422 on this endpoint.
  body: {
    tool_body_type: 'twilio',
    variant_type: 'primary',
    account_sid: 'AC…',           // "AC" + 32 hex
    auth_token: '…',              // writeOnly — never comes back on a read
    from_number: '+15550000001',  // XOR with messaging_service_sid
    // base_url    — nullable; blank means https://api.twilio.com
    // timeout_ms  — 1..300000, nullable
    // base_message — ComplexTemplateConfig
  },
})
```

**`from_number` and `messaging_service_sid` are mutually exclusive.**
Twilio resolves the messaging service and ignores `From` when both are
sent, so a tool carrying both could no longer say which number a message
went out on — and that join is the entire reason one tool exists per
sender identity. The type permits both because the spec cannot express
XOR; the server rejects it.

There is deliberately **no `status_callback_url`**. The platform polls
Twilio's Messages endpoint; a callback field would advertise a path that
does not exist.

### One credential, many senders: `variant_type` and `primary_tool_id`

Every sender body — `twilio`, `sns`, `end_user_messaging`, `email` —
declares which of two things it is:

```
variant_type = 'primary'   holds its own credential, borrows from nothing
variant_type = 'variant'   borrows; primary_tool_id is REQUIRED
```

The problem this solves: one Twilio account behind three numbers used to
mean three tools each holding the same `account_sid` and `auth_token`.
Rotation was N edits, and a missed one is a **silent partial outage** —
the other senders keep working, so nothing looks broken.

**A variant stores no credential at all.** Reading one back, its
`account_sid` / `api_key` / `access_key_id` / `smtp_*` come back `null`.
**That is correct and complete, not missing data.** Do not "repair" it by
copying the primary's credential in — that recreates the exact bug this
removes. The platform resolves the primary at dispatch.

**Rotation is one write, to the primary.** Any helper that "updates
credentials across senders" is now wrong by construction: a partial
rotation has no representation.

**Depth is exactly one**, enforced by a database trigger rather than a
changeset, because the rule reads another row. A violation is a **422
with a field on it** — surface the message, do not retry:

```
names a tool that is itself a variant — a variant must name a primary
names a tool of another type — a variant borrows from a primary of its own type
names a primary on another provider — a variant borrows one account's credentials
```

What stays with the variant is the sender identity and its event routing:

```
twilio              from_number XOR messaging_service_sid
sns                 phone_number
end_user_messaging  phone_number · configuration_set_name
email :mailgun      from_email · from_name · reply_to · domain
email :ses          from_email · from_name · reply_to
email :sendgrid     from_email · from_name · reply_to
email :smtp         from_email · from_name · reply_to
```

Everything else — auth, region, endpoints, the MMS staging bucket — comes
from the primary. Two entries look arbitrary and are not: Mailgun's
`domain` is also a path segment in the request URL
(`/v3/{domain}/messages`), and End User Messaging's
`configuration_set_name` is the *event-routing* address, of which AWS
permits one per originating number — a number that cannot name its own
routing cannot be reconciled apart from its siblings.

The split is per body type, and for Email per **provider**. That is the
concrete reason there is no global credential registry: no single list
could express it.

### `intent` and `tool_body_type` are loosely coupled

The TypeScript type lets you combine any intent with any body
type, but the platform validates the pairing semantically at
create time:

```
   data_exchange  →  s3, sftp, sql_database, rest_api,
                     manual_upload, aws_lambda, sharepoint, sqs
   sms            →  sns, rest_api (provider-specific)
   mms            →  end_user_messaging
   email          →  email (dedicated body), rest_api,
                     aws_lambda (custom dispatchers)
   voice          →  rest_api (Twilio Voice, etc.)
   export         →  s3, aws_lambda
   status_poller  →  cloud_watch_log_group, rest_api
   llm_enrichment → rest_api (against an OpenAI-compatible LLM
                              endpoint; the platform appends
                              /chat/completions to base_url; the
                              tool is the complete LLM provider
                              adapter and AI agents bind it for
                              structured row enrichment)
```

A misaligned pair (e.g. `intent: sms` + `tool_body_type: sftp`)
returns a `422` with `body.tool_body_type` in `errors`.

### `data_source_id` is optional but discriminator-aware

When set, the tool is **embedded under a data source** — its
lifecycle is tied to the parent data source, and ingestion paths
expect it at that scope. When omitted, the tool is **standalone**
on the datalake — available to any workflow that names it.

The body type itself signals the typical pattern: `manual_upload`
is almost always embedded (the data source IS the upload sink);
`sns`, `rest_api`, `sql_database` are typically standalone.

### Body-type sub-fields are validated against the matching discriminator branch (server-side)

Each branch has its own required-list. Common patterns:

```
   sns body                 region, auth_method, plus EITHER
                              (access_key_id + secret_access_key
                                + optional endpoint_url)
                            OR (iam_role)

   end_user_messaging body  region, auth_method (same three-way
                              access_key / iam_role / assume_role
                              shape as sns — gotcha 3), phone_number
                              (E.164 origination number), media_bucket,
                              configuration_set_name, plus EITHER
                              (access_key_id + secret_access_key)
                            OR (iam_role)
                            OR (assume_role_arn +
                                assume_role_external_id);
                              optional endpoint_url (ONE override,
                              covering everything the tool reaches:
                              the SendMediaMessage send, S3 media
                              staging, and the save-time credential
                              probe; blank ⇒ each service resolves to
                              its own regional AWS host)

   email body, provider ses from_email, region, access_key_id +
                              secret_access_key (a long-lived IAM key:
                              there is no session-token field, so
                              temporary credentials cannot send);
                              optional endpoint_url — the WHOLE request
                              URL, e.g. a local AWS stand-in at
                              http://localhost:4566; blank ⇒
                              https://email.<region>.amazonaws.com

   sql_database body        db_type, db_host, db_port, db_name,
                              auth_method, db_username,
                              db_password (or iam_role)

   s3 body                  region, bucket, auth_method,
                              access_key_id, secret_access_key
                              (or iam_role), optional base_path

   sftp body                hostname, port, username, auth_method,
                              password (or private_key)

   rest_api body            base_url, auth_method (oauth2 /
                              oidc / api_key / basic), plus the
                              corresponding credential block
```

The full per-branch shape lives in
`packages/sdk/src/generated/types.gen.ts` under the matching
`Tool<Type>BodyWritable` types.

### Authoring an End User Messaging tool locally — every auth method works

`endpoint_url` is one override for the whole tool. Point it at a local
AWS-compatible stub and the send, the media staging, and the
save-time credential probe all follow it; leave it blank and each
service resolves to its own regional AWS host. There is no second
endpoint field — `media_bucket` names a bucket, not a host.

All three auth methods can be authored locally **and** against real
AWS. They do not all reach the same host, and are not meant to:

```
   auth_method    endpoint_url set (local)        endpoint_url blank
   ───────────────────────────────────────────────────────────────────
   access_key     everything → your stub          everything → AWS
   iam_role       everything → your stub, but     everything → AWS
                    the credential itself still
                    comes from the platform's
                    ambient chain
   assume_role    send + staging → your stub;     everything → AWS
                    the STS AssumeRole leg is
                    NOT tool-steered (below)
```

**Why `assume_role` deliberately splits.** That STS request is signed
with the **platform's own** ambient credentials — your role trusts a
platform identity, and the tool contributes only `assume_role_arn`
(the role in YOUR account) and `assume_role_external_id`. Letting a
tool field steer it would send a live SigV4 signature made with the
platform task role to a host the tool's author picked. So the STS
endpoint is a per-deployment constant, resolved by the platform, and
`assume_role` is the one auth method where the tool does not choose
it. This is by design — not a gap, and not a reason to prefer another
auth method when authoring locally.

If a local `assume_role` save is reaching real STS when you expected a
stub, that is the platform deployment's own STS configuration, not the
tool body. Nothing you can put in `endpoint_url` changes it.

### `assume_role` needs the platform's ARN — read it from `GET /api/ping`

The `assume_role` auth method (on `sns` and `end_user_messaging`
bodies) is configured from two values that come from opposite
sides of the trust relationship:

```
   assume_role_arn          the role YOU create in YOUR AWS
                              account, for the platform to assume
   assume_role_external_id  the platform-generated external ID
                              that role must require
```

Creating that role means naming a principal in its trust policy,
and that principal is the platform's own IAM task role. It is
readable from the unauthenticated health endpoint:

```typescript
const { data } = await api.ping()
// data.iam_role_arn — e.g.
//   'arn:aws:iam::123456789012:role/alvera-platform-task'
```

Three things to hold onto:

- **`api.ping` takes no auth**, so the ARN is readable before a
  session exists — onboarding flows and setup scripts can fetch it
  on first contact with the platform.
- **It differs per environment.** Read it from the environment you
  are configuring against; never hardcode one and carry it across.
- **It can be `null`.** Where the platform runs without AWS — the
  normal case in local development — there is no task role to
  trust. The generated `PingResponse` types `iam_role_arn` as
  optional and `string | null` for exactly this reason, so handle
  the null at every read rather than assuming a string.

The ARN being public is deliberate: it has to reach every customer
for onboarding to be possible. What stops a third party from
pointing their own tool at your role is the
`assume_role_external_id` — your trust policy must require that
external ID, so possession of the ARN alone grants nothing.

### `status` field gates whether the tool can be invoked

```
   active     tool is invocable; test-invocation + workflow steps work
   inactive   tool exists but invocations short-circuit with
                an explanatory error envelope
```

Most callers set `status: 'active'` on create; flipping to
`inactive` is the soft-delete / pause pattern.

### `aws_lambda` body has a second discriminator: `type`

Inside the `aws_lambda` body (selected by the outer
`tool_body_type: "aws_lambda"`), the wire carries a second
discriminator field literally named `type`:

```
   body.type = "managed"   →  the platform owns the function;
                              create + deploy + delete go through
                              the platform's CloudFormation stack
   body.type = "external"  →  the function is operator-managed in
                              an AWS account; the platform only
                              invokes it
```

The two paths require disjoint field sets:

```
   type: "managed"
     required:   ssm_config_key (string — names the config
                                 partition in SSM Parameter Store
                                 the function reads at runtime)
     accepted:   secrets[]      (array of { key, value } — material
                                 the function reads as runtime
                                 secrets; values are sensitive,
                                 treat as write-only on the manifest
                                 side via `<%= name %>` per §6 gotcha 10)
                 env_vars[]     (array of { key, value } — passed
                                 to the function as plain environment
                                 variables; non-sensitive)
     server-set: function_arn   (populated after deploy succeeds)
                 stack_id, stack_name, stack_status
                                (CloudFormation tracking; see
                                 §2 "aws_lambda tools have an
                                 async deployment lifecycle")

   type: "external"
     required:   function_arn   (the existing AWS function to invoke)
                 auth_method    ("access_key" or "iam_role")
     access_key:  access_key_id required;
                  secret_access_key write-only at create
     iam_role:    no AWS credentials required; the platform
                  assumes the role at invocation time
```

### `aws_lambda` tools have an async deployment lifecycle

For `type: "managed"`, creating the tool registers the row but
DOES NOT deploy. An async CloudFormation worker packages and
uploads the function; poll the tool's **`body.stack_status`** field
(the CloudFormation deploy state, nested under the polymorphic
`body` — there is no top-level `deployment_status`):

```
   create_in_progress   stack being built
   create_complete      function is invocable; tool.status flips to 'active'
   create_failed        deployment error — reason in `body.error`
   (redeploys use update_in_progress / update_complete / *_failed;
    the full CloudFormation state set is on the generated stack_status enum)
```

Two consumer-relevant consequences:

- **Managed tools cannot be created with `status: "active"`.**
  The platform rejects with `/status: "cannot be set to active
  until Lambda deployment is complete"`. Create with
  `status: "draft"` (the typical pattern for managed lambdas),
  trigger deployment, poll `body.stack_status` until
  `create_complete` (the top-level `tool.status` auto-flips to
  `"active"` on success — no manual `update()` needed to activate).
- **`function_arn` is server-populated for managed tools.** Do
  not supply it on `create()` / `update()` for `type: "managed"` — the platform
  writes it after the CloudFormation stack reaches a
  create-complete state.

For `type: "external"`, there is no deployment phase — the row
is invocable as soon as the create returns, and `function_arn`
is caller-supplied. Other body types (`s3`, `sns`, `rest_api`,
…) likewise have no deployment phase.

### `rest_api` body with `auth_method: "oidc"` probes the token endpoint at create

For `auth_method: "oidc"`, the platform fetches an access token
from the configured `oidc_issuer_url` using
`oidc_client_id` + `oidc_client_secret` **before** the row is
written. The probe surfaces in field-level rejections:

```
   401 from token endpoint     →  /oidc_client_id:
                                   "invalid credentials — token
                                    endpoint returned 401"
   other non-200               →  /oidc_issuer_url:
                                   "token endpoint returned HTTP {status}"
   network unreachable         →  /oidc_issuer_url:
                                   "cannot reach token endpoint: {reason}"
```

The probe is skipped when any of the required OIDC fields
(`oidc_issuer_url`, `oidc_client_id`, `oidc_client_secret`) are
missing — in that case the request fails on the missing-field
check before any HTTP call. Other `auth_method` values
(`oauth2`, `api_key`, `basic`, `none`) do not probe — only the
OIDC path runs the token-endpoint reachability check.

The probe runs on `update()` too; an update that re-supplies any
of the three OIDC fields re-validates the token endpoint, so a
silent OIDC-provider outage can fail an otherwise-cosmetic
`update()`.

## 3. Field ownership conventions

### Caller-supplied

```
   identification + metadata
     name                 human-readable label (unique per datalake)
     description          (nullable) free text
     intent               ToolIntent enum value
     status               "draft" | "active" | "inactive" | "error" | "marked_for_deletion"
                            'draft'               — created but unconfigured (typical for
                                                    lambda tools awaiting deployment)
                            'active'              — invocable; the steady state
                            'inactive'            — paused / soft-deleted; invocations
                                                    short-circuit (see §2)
                            'error'               — failure state; surfaces via UI banners
                                                    and gates re-invocation until resolved
                            'marked_for_deletion' — cleanup in flight (lambda undeploy etc.);
                                                    transient before DB row removal

   scoping
     datalake_id          parent datalake (UUID)
     data_source_id       (optional) parent data source for embedded
                          tools; omit for standalone

   body (polymorphic — required keys depend on tool_body_type)
     tool_body_type       discriminator — names which branch shape
                          the rest of `body` must satisfy
     <branch-specific>    auth + endpoint + credential fields
                          (use `<%= name %>` Eta refs for credentials;
                          never literal values)

   response_extractor     TemplateConfig ({ type: 'custom' | 'system' })
                          — maps the raw tool response into the canonical
                          enrichment result. REQUIRED when
                          `intent: 'llm_enrichment'` (a `:null` template
                          is rejected with `/response_extractor
                          "is required for llm_enrichment tools"`);
                          ignored for every other intent.
```

### Server-derived

```
   id                     UUID
   slug                   derived from name (lowercase + hyphen)
   created_at             ISO timestamp
   updated_at             ISO timestamp
   body.stack_status      managed lambda tools only — CloudFormation
                          deploy state (create_complete = ready)
   body.error             managed lambda tools only — deploy failure reason
```

Capture both `id` and `slug` from the create response; never
pre-compute either client-side. The **UUID `id`** is the CRUD path
key (`.get` / `.update` / `.delete`) AND the cross-resource
reference — workflows and data-activation clients bind a tool by
`id` (`tool_id`), and action status updaters by `updater_tool_id` /
`sender_tool_ids` (see §7). The `slug` is the human handle; it does
NOT key a CRUD `.get` (passing it 422s `Expected :uuid`). See
`type_naming.md` for the id/slug/name convention.

## 4. Error envelopes

A `422` from `create()` / `update()` returns:

```
   {
     "errors": {
       "field.path": ["check message", …],
       "body.tool_body_type": ["is invalid"],
       …
     }
   }
```

Walking each entry:

1. Look up the field on `ToolRequestWritable` (or the relevant
   `Tool<Type>BodyWritable` branch when the path starts with
   `body.`). The TS type's JSDoc names the semantic role.
2. Read the 422 `detail` for the precise constraint that fired
   (the field tables in this MD list each branch's required-list).
3. For `body.<...>` failures, the FIRST diagnostic is usually
   `tool_body_type` — fix the discriminator before chasing other
   fields, because the wrong branch's required-list will fire
   spuriously when the discriminator is wrong.

Common rejections:

```
   field check                                    cause
   ─────────────────────────────                 ─────────────────────────────
   "is invalid" on intent                         value not in ToolIntent enum
   "is invalid" on tool_body_type                 value not one of the
                                                    twelve body-branch
                                                    discriminator literals
   "is invalid" on body                           intent ↔ body_type pairing
                                                    rejected at platform level
   "can't be blank" on a body sub-field           branch-specific required
                                                    field omitted
   "must match auth_method" on credentials        e.g. `iam_role` set but
                                                    `access_key_id` also supplied
```

## 5. Lifecycle

### Create

`POST /tenants/:tenant_slug/datalakes/:datalake_slug/tools`
returns `201` with the full tool shape including server-derived
fields. For non-lambda body types the tool is invocable
immediately; for `aws_lambda` the deployment is async (see §2).

### Test invocation

`POST /tenants/:tenant/datalakes/:datalake/tools/:id/test-
invocation` exercises the tool end-to-end against the live
external system.

The request body wraps the call payload under `tool_call`, whose
own polymorphic `tool_call_type` discriminator selects the call
shape:

```typescript
await api.tools.testInvocation(
  tenantSlug, datalakeSlug, toolId,
  {
    tool_call: {
      tool_call_type: 'sms_request',   // 'email_request' | ...
      to:   { type: 'custom', body: '+15551234567' },
      body: { type: 'custom', body: 'Hello' },
      sms_type: 'transactional',       // sms_request-specific
      link_prefix: 'https://go.example.com/l',   // required on sms / mms / email
    },
  },
)
```

`to` and `body` are each `{ type, body }` embeds — `type: 'custom'`
inlines the value verbatim; `type: 'system'` references a
platform-shipped template (see `templates.md`).

**Test-invocation accepts SIX `tool_call_type` variants** — the
interactive ones. Its request casts into a `ManualToolInvocation` embed
(`on_type_not_found: :raise`) whose polymorphic set is `sms_request`,
`mms_request`, `email_request`, `restapi_request`, `aws_lambda_request`
and `sql_query`.

THREE LISTS LIVE HERE and conflating them is the mistake this paragraph
used to make. `tool_body_type` says what a tool IS (12 variants — `sns`,
`twilio`, `s3`, `sql_database`, `sftp`, `sharepoint`, `manual_upload`, …).
`tool_call_type` says how a CALL is shaped (9). Test-invocation accepts a
subset of the call types (6). They do not map one-to-one: `sns` and
`twilio` are both bodies that send SMS, so both call through
`sms_request`; the `sql_database` body calls through `sql_query`.

So a tool whose body is **S3, SFTP or SharePoint** has no test-invocation
variant and cannot be exercised through this endpoint (same shape as the
`status_poller` case below). SFTP and SharePoint are coming; S3 is not
currently reachable this way either.

**`manual_upload` is the exception worth knowing.** It is excluded from
test-invocation, but it is NOT unexecutable — it has a real executor
(`Platform.Tools.ManualUpload`, protocols.ex:105) which routes to the
`ingest_json` path rather than the automated fetch pipeline. There is
nothing to dial interactively because the input is a file someone
supplies; the tool still runs. A born-red contract that demands a
test-invocation smoke for a manual-upload tool is therefore asking for
something that cannot exist, and the cure is to assert the tool's
configuration and its provenance binding instead.
Those config shapes still exist — but on the broader **workflow-action
`ToolCallConfig`** surface (see `tool-call-configs.md`), not here. The
per-branch required-list for the five test-invocable variants:

```
   tool_call_type: 'sms_request'
     required:   to, body, sms_type, link_prefix
     optional:   link_validity_days (default 365)
     enums:      sms_type ∈ { 'transactional' (default), 'promotional' }
     notes:      every URL in body goes out as <link_prefix>/<token> —
                 see authenticated_links.md

   tool_call_type: 'mms_request'
     required:   to, body, media_url, link_prefix
     optional:   link_validity_days (default 365)
     notes:      body is the message caption; media_url is a PLAIN
                 STRING — a public http(s) URL, no Liquid. The server
                 verifies it at authoring (GET must answer 2xx with an
                 image/* content-type; malformed/non-http 422s without
                 fetching), then re-stages it into the tool's
                 media_bucket before send — targets a tool whose body
                 is end_user_messaging

   tool_call_type: 'email_request'
     required:   to, subject, body, link_prefix
     optional:   link_validity_days (default 365)
     notes:      every href in body goes out as <link_prefix>/<token>
     embeds:     TemplateConfig variants accepted on each ({ type:
                 'custom', body } or { type: 'identity' } or
                 { type: 'system', path }); 'null' is not valid here

   tool_call_type: 'restapi_request'
     required:   path, method, pagination_context_template
     enums:      method ∈ 'head' | 'get' | 'put' | 'post' | 'delete' |
                 'patch' — LOWERCASE. 'POST' is rejected as
                 `/tool_call/method — Invalid value for enum`.
     optional:   body, params
     shapes:     path, body, params and pagination_context_template are
                 EACH a TemplateConfig ({ type, body }) — 'identity' or
                 'custom' both accepted. `path` is NOT a plain string;
                 passing one is `/tool_call/path — Invalid value: Invalid
                 object. Got: string`. It is an envelope so a path can be
                 interpolated per row.
                 pagination_context_template is required even with nothing
                 to paginate — `{ type: 'null' }` is how you say so.

   tool_call_type: 'aws_lambda_request'
     required:   payload (TemplateConfig — 'identity' acceptable)
     constraints: timeout_ms in inclusive range [1000, 900_000]
                  (defaults to 30_000 ms when omitted)
```

The `sql_query`, `sftp_request`, and `microsoft_share_point_excel_request`
shapes (with their own required-fields and constraints — e.g.
`sheet_number >= 0`, `content_type ∈ {json, ndjson, csv, …}`) belong to
the workflow-action `ToolCallConfig` catalog in `tool-call-configs.md`;
they are **not** part of test-invocation.

Liquid template bodies (`query`, `payload`, body/params on
`restapi_request`, `sms_request` body, `mms_request` body,
`email_request` body) are parsed at create time — invalid Liquid
syntax surfaces as a field rejection on the corresponding
TemplateConfig path, not on the outer request.

The response shape:

```
   {
     id:           UUID of the recorded invocation
     status:       "success" | "error"
     error_message: string | null
     tool_call:    the input echoed back, with provider metadata
   }
```

This endpoint **never returns a 5xx for tool-side failures** —
provider errors (bad credentials, network refused, misconfigured
auth) surface as `status: "error"` with `error_message` populated.
Only platform-internal failures (DB unreachable, etc.) produce
5xx. Treat 5xx as infra noise; treat `200 + status:"error"` as a
real (and handleable) configuration problem.

### Proving a `status_poller` tool — it depends on the BODY, not the intent

Invocability is keyed on the tool's **body type**, not its intent.
`restapi_request` is the call type for ANY intent with a `rest_api`
body, so a `status_poller` with a `rest_api` body — the shape a
provider events-API poller takes — **can** be test-invoked, and
that is the cheapest way to prove its credentials:

```typescript
await api.tools.testInvocation(tenantSlug, datalakeSlug, pollerId, {
  tool_call: {
    tool_call_type: 'restapi_request',
    method: 'get' as const,
    path: { type: 'custom', body: '/v3/YOUR-DOMAIN/events' },
    params: { type: 'custom', body: '{"limit": 1}' },   // keep it read-only
    // REQUIRED, even for a one-shot probe: a REST call must always say
    // whether there is another page. Omit it and the create is refused.
    pagination_context_template: { type: 'custom', body: '{"has_next": false}' },
  },
})
```

A `cloud_watch_log_group` body is the one that has no call type —
nothing in the closed `tool_call_type` set fits it. **That** poller's
run surface is the action status updater that fetches through it:
its `last_run_at` / `last_run_status` / `last_run_error` /
`last_run_events_found` fields (excluded from the drift checksum, so
a poll never reads as config drift) plus the `Last run` section of
the ASU's AI metadata. A failed poll lands its structured reason in
`last_run_error`.

You do **not** have to wait for the cron. `actionStatusUpdaters.refresh`
fires one poll cycle on demand (CLI: `alvera refresh-action-status-updater
--id <uuid>`), and reading `last_run_*` correctly is what makes it
usable as a proof — four things to get right:

- **`refresh` answers `202` with the updater row AS-IS.** The poll runs
  asynchronously, so the response carries the PREVIOUS run's stamps.
  Capture `last_run_at` as a baseline BEFORE calling it, then poll the
  row until `last_run_at` advances past that baseline.
- **`refresh` can answer `409` and queue nothing.** A halted updater
  (`status` not `'active'`) refuses until you set `status` back to
  `'active'`. An updater with a poll already queued or running refuses
  until that poll finishes; retry then. The error's `detail` says which.
- **`last_run_status` has two values.** `'ok'` means the run read its
  whole time window; `'error'` means it stopped, and `last_run_error`
  says why. There is no partial run: every page of the window is read,
  one job per page, with no page cap.
  `last_run_events_found` counts the current run's events, and because
  `refresh` is async an early read can show an in-progress figure.
- **`status` is the halt flag.** It is `'active'` normally and the
  server sets `'cycle_detected'` when a run re-reads events it has
  already handled — every later job then fails without calling the
  provider, and only setting it back to `'active'` resumes polling. It
  is also the fastest deterministic failure to assert on: a
  non-advancing pagination config trips it within seconds, no provider
  credential rejection required.

See `action_status_updaters.md` §7 for the full refresh + `last_run_*`
contract. A born-red contract for a CloudWatch poller should assert the
tool's own config truths and prove the polling behaviour through the
owning ASU on those terms.

### Update

`update()` replaces the whole resource — resupply the full
body; there's no partial update. The polymorphic discriminator
can't change on `update()` — it must match the existing row's
`tool_body_type`; to change body type, `delete()` and `create()`
fresh.

### Delete

`delete()` removes
the row. For `aws_lambda` tools, this also enqueues a
`trigger_lambda_deletion` job — poll until the AWS function is
gone. Other body types delete synchronously.

### Ordering

A tool MUST be created AFTER its parent datalake reaches
`status: "ready"`, and (if embedded) AFTER its parent data source
exists. Workflows and data activation clients reference tools by id, so any
downstream resource that names a tool must apply AFTER it.

## 6. Gotchas

1. **Discriminator wire name is `tool_body_type`** — not `type`,
   not `kind`, not `__type__`. Always use the wire name on the
   request body.

2. **`manual_upload` body is the only branch with no auth /
   endpoint fields** — its presigned URLs are issued by the
   platform per-upload. `body: { tool_body_type: 'manual_upload' }`
   with no other body fields is the full create body.

3. **`sns` tools with `iam_role` auth + no `endpoint_url` MUST be
   tested against real AWS** — the platform refreshes SSO
   credentials at invocation time. In dev / E2E, use
   `auth_method: 'access_key'` + `endpoint_url:
   'http://localhost:4566'` for LocalStack routing.

4. **Test-invocation graceful-error contract is load-bearing** —
   any code path that crashes the test-invocation route into a 500
   is a regression. Misconfigured tools must surface
   `status: "error"` + non-empty `error_message`. If you see a
   5xx, file a platform bug, do not retry.

5. **Standalone vs embedded tool routing differs at list time**
   — `tools.list(tenant, datalake, query?)` returns ALL tools on
   the datalake (standalone + embedded). The Flop query
   (type_naming.md) filters on `intent`, `status`, `name`, etc. —
   but `data_source_id` is NOT in the filterable set, so tools
   owned by a specific data source must still be filtered
   client-side.

6. **Tool slug uniqueness is per-datalake, not per-tenant** —
   two datalakes on the same tenant can have tools with the same
   slug. Always scope tool references via the datalake slug,
   never just the tool slug.

7. **`tools.metadata(tenant, datalake)` returns a Markdown
   catalog** — useful as a discovery surface for downstream
   tooling. `tools.metadataDetails(tenant, datalake, toolId)`
   returns Markdown for a single tool including the body
   discriminator + per-branch fields.

8. **`update()` replaces the whole resource** — to flip `status`
   from `active` to `inactive`, resubmit the entire body including
   all credential / endpoint fields; there's no partial update.
   Forgetting the body branch discriminator on `update()` triggers
   the "tool_body_type required" rejection.

9. **Lambda `body.stack_status` is the readiness signal, not the
   top-level `status`** — for managed `aws_lambda` tools, the
   CloudFormation deploy state lives at `body.stack_status`; readiness
   is `create_complete` (there is no `deployment_status` field). The
   top-level `tool.status` auto-flips to `"active"` on a successful
   deploy, so watch `body.stack_status`, not `status`, for "is it
   deployed yet."

10. **Credentials in the body MUST go through `<%= name %>`
    Eta references** when the tool is authored via a manifest — never
    literal strings on disk. The flat top-level identifier form is the
    safe shape (`<%= secrets.name %>` nested under a `secrets` object
    silently renders the string `"undefined"` if the key is missing).
    The SDK does not enforce this (it accepts literals), but secret
    references are the required operating mode for any non-test
    environment.
