Ensemble Docs

Operations

The operation contract. Methods, path templates, input binding, agent targets, transforms and declared responses.

An operation is one method and one path, bound to one tool or agent. Everything a caller can send and everything they get back is declared here, and most of it is checked when you save rather than when somebody calls.

A definition that routes ambiguously, or whose path parameters do not match its declared inputs, cannot be saved at all. Those are the mistakes that would otherwise surface as a wrong response or a 500, so they are refused at the point they are made.

Method and safety

safety is what you promise about the operation in HTTP's own terms, and the platform holds you to it per method.

SafetyMeaningRequired for
safeNo side effects at all.GET
idempotentRepeating it has the same effect as doing it once.PUT, DELETE
unsafeNeither. The default, and all POST usually needs.—

Nothing about a tool reveals which of these it is, so you assert it. Two things depend on the assertion:

  • Read-only grants may call only safe operations. This is why it is a safety declaration rather than a method allowlist: a search that takes a large body is a POST here and is still safe.
  • Idempotency-Key is honoured only for operations that are not safe. Replaying a read costs a stored row for no benefit.

PUT and DELETE may not be unsafe, because callers and proxies retry them on the assumption that they are not.

Path templates

/coverage/{employeeId}
RuleWhy
Starts with /, no trailing /, no empty segment/ alone is the API root
A segment is a literal or exactly one {param}No regex, optional segments or multi-segment wildcards
Literals are lowercase letters, numbers and hyphensReadable, and unambiguous against parameters
The first segment must be a literalKeeps the operation recognisable in a log
At most two path parametersBeyond that, a query parameter is clearer
Parameter names are unique within the templateA duplicate cannot be bound
Segments may not start with _Reserved for platform paths such as _openapi.json

Matching is path-first, most specific wins. /coverage/count is tried before /coverage/{employeeId}, so a literal beats a parameter at the leftmost differing segment. Once a path matches, the method is looked up within it; if the path exists but the method does not, the answer is 405 with an Allow header listing that path's methods.

There is never a tie, because two operations on the same method whose templates could match the same path are refused at save time. A path segment that is not valid percent-encoding is a 400, not a 404.

Inputs

Inputs are keyed by the target's argument name, so dispatch is a pick, a rename and a coercion with no mapping layer in between.

FieldPurpose
inpath, query, header or body. Mirrors OpenAPI's in, minus cookies.
asThe public name, when callers should use something other than the argument name.
fromA dot path into the request body, for callers who send nested JSON. Body only.
defaultUsed when the caller omits the value. Not allowed together with required.
requiredRefuses the request when absent.

Where a value may come from

inConstraints
pathAlways required. Must be string, number or boolean, never an array. The template must have a matching {param}.
queryMay not be an object or an array of objects.
headerMatched case-insensitively, since Node lower-cases incoming header names.
bodyNot allowed on GET or DELETE, whose semantics forbid a body in practice.

Template placeholders and declared path inputs must correspond exactly, in both directions. A {param} with no input, or a path input with no {param}, is refused when you save rather than left to become a 500.

Coercion

Path, query and header values arrive as strings and are coerced to the declared type. Body values arrive from JSON already typed and are checked rather than coerced.

That asymmetry is deliberate. Accepting the string "42" for a declared number in a body would mean the same contract behaves differently depending on where the value was carried.

DeclaredFrom a string
numberMust parse finite. An empty or blank value is refused, because Number('') is 0.
booleantrue, 1, yes / false, 0, no, case-insensitive.
stringTaken as-is.

A failed coercion is a 400 naming the field.

Renaming, and reaching into a body

inputs:
  employeeId:          # the tool's argument name
    in: path
    as: employee_id    # what the caller writes: /coverage/{employee_id}
  region:
    in: body
    from: account.region   # caller sends { "account": { "region": "eu" } }
  pageSize:
    in: query
    default: 25

Note that as changes the public name everywhere it appears, including in the path template: the template says {employee_id}, and the tool receives employeeId.

Mapping is limited to those three knobs on purpose. Anything computed belongs in a tool, which is versioned, testable and observable; an expression evaluated during binding would be a second place where behaviour lives.

Targets

An operation points at the logical tool or agent, not at a particular stored revision, and resolves a revision per call.

Target versionResolves to
publishedThe newest published revision. The default, and almost always right.
A version numberThat revision, pinned. For an endpoint that must not move.
latestThe newest definition including an unpublished draft.

latest is only accepted on an API whose contract version is itself latest. On a versioned API it is refused, because a caller of v1 has no way to know a draft is answering them. See Versions.

Agent targets

An agent is asked in words, so an agent operation needs a message template and a tool operation must not have one.

Summarise the coverage for employee ${employeeId} in plain English.

Placeholders name the keys of inputs — the argument names, not the public names an as gives them. A bare identifier that is not a declared input is refused when you save, because ${employeeld} with a lowercase L would otherwise interpolate as empty and quietly change the prompt. Qualified expressions such as ${user.email} resolve against the evaluation context instead and are left unchecked.

A template of exactly ${message} with a declared message input is a passthrough, which is how a chat-style endpoint is built.

Two further requirements for an agent target:

  • It must declare structured output. Without it there is nothing to return.
  • A supervisor cannot be a target.

Conversations continue through x-thread-id; see Calling an endpoint.

Responses

Transform

An optional pipeline runs over the target's output before it is returned. Steps run in order:

StepWhat it does
extractEvaluates a JEXL expression against the output, for example data.results.
arrayToMapTurns an array into an object keyed by a field of each item.

A transform is the right place to narrow a large upstream payload to the handful of fields your contract promises.

extract takes an expression, so a mistake does not fail. A wrong path resolves to undefined and the caller gets an empty 200. Use Preview in the editor against a real sample, which is what makes this usable rather than merely present.

Declared output schema

An operation may declare a JSON Schema for what it answers with, after any transform. It is used twice:

  • In the document, so a generated client is typed rather than handed any.
  • At dispatch, where the body is checked against it.

A response that does not match is logged and still sent. A mistake in a declared schema should cost observability, not uptime. That log line is the only thing that notices the contract and the target have drifted apart, so it is worth watching.

Only schemas the platform can actually check are accepted; anything else is refused when you save, rather than looking like a schema that passes when nothing is being checked.

Success status

Any 2xx. A 204 sends no body, which is what lets a delete operation declare one.

Size and time budgets

BudgetDefaultExceeded
Response size1 MiB413 RESPONSE_TOO_LARGE
Operation runtime30s, per-API override up to 300s504 TIMEOUT

Both are bounds rather than optimisations. An operation returning megabytes is a definition that needs a transform, and an unbounded one costs the caller's time, a full walk in the output-shape check, and a stored row for an idempotent call.

On this page