Skip to content

Serving Extensions

An extension is a small HTTP service that Agent Studio calls. It runs in its own pod, next to the agents. latent.extension writes one in Python, and latent serve --extension runs it. Studio's Node SDK (@agent-studio/extension-server in agent-studio) speaks the same contract.

An extension can serve:

  • gates — Studio calls a gate in one lifecycle phase (before_conversation, before_agent, after_agent, after_conversation), and the gate decides continue, veto, escalate or inject;
  • processors — Studio sends the conversation; a terminal processor scores it after it ends, a live processor classifies it while it runs;
  • routes — Studio serves each route at /x/<extension>/<path>, to signed-in Studio users (studio-user) or to anyone (public).

The extension only declares what it serves. A Studio admin decides which gates run where (bindings) and which processors run (processor configs). The Studio side is documented in agent-studio's docs/extensions.md.

Write an extension

import hashlib

from latent.contracts import ConversationData, GateRequest
from latent.extension import (
    Extension, ExtensionRequest, GateDecision, ProcessorResult,
    cont, processed, veto,
)

ext = Extension("demo-rules")


@ext.gate(phase="before_conversation", name="g8_probe", timeout_ms=800)
async def g8_probe(req: GateRequest) -> GateDecision:
    """Veto customer keys that start with deny:."""
    if (req.customer_key or "").startswith("deny:"):
        return veto("denied_by_rule")
    return cont()


@ext.processor(name="reply_length", when="terminal", processor_type="scale",
               timeout_ms=5000, display_name_key="processors.replyLength")
async def reply_length(conv: ConversationData) -> ProcessorResult:
    """Score the total reply length (1.0 at 1000 characters)."""
    chars = sum(len(m.content) for m in conv.messages if m.role == "assistant")
    return processed({"chars": chars}, score=min(chars / 1000, 1.0))


@ext.route("whoami", methods=["GET"], auth="studio-user", roles=["admin", "sme"])
async def whoami(request: ExtensionRequest) -> dict[str, str | None]:
    """The signed-in Studio user."""
    return {"id": request.studio_user.id, "role": request.studio_user.role}


@ext.route("public/echo", methods=["POST"], auth="public")
async def echo(request: ExtensionRequest) -> dict[str, str]:
    """The sha256 of the raw request body."""
    return {"sha256": hashlib.sha256(request.raw_body).hexdigest()}

Gates

@ext.gate(phase=..., name=..., timeout_ms=...) declares an async (GateRequest) -> GateDecision handler. Build the decision with cont(), veto(reason), escalate(reason) or inject(**fields).

  • timeout_ms must be within 100–5000 ms. Studio gives the gate its full timeout or does not call it.
  • The gates of one phase share a budget: 3000 ms for before_conversation, 12000 ms for before_agent, 20000 ms for each after_* phase. Extension refuses a declaration that would exceed it.
  • needs_ticket=True means Studio calls the gate only when the channel has ticket facts (the Glassix claim path). The request then carries ticket, handoffs and the top-level handoffs_lookback_hours those hand-offs were read for.

Processors

@ext.processor(...) declares an async (ConversationData) -> ProcessorResult handler. Return processed(result, score=..., tags=[...]) or processor_failed(error).

  • processor_type is thumbs or scale.
  • A terminal processor may take up to 60 s; a live one up to 10 s.
  • A live processor's first tag becomes the conversation's classification tag.

Routes

@ext.route(path, methods=[...], auth=...) declares a route. The handler receives an ExtensionRequest (method, path, params, query, headers, raw_body, studio_user) and returns JSON-ready data or a Starlette Response.

  • A studio-user route receives the user in request.studio_user. Studio refuses users whose role is not in roles.
  • A public route must start with public/. It takes no roles.
  • A path segment is a literal or one {param}, for example items/{itemId}.
  • max_body_kb (default 256) and timeout_ms (default 10000) bound a call.
  • verify_headers (public routes only, up to 4 lowercase names) lists the headers a third party signs its callback with, for example ["x-hub-signature-256"] or ["stripe-signature"]. Studio forwards exactly those, so the handler can verify the sender over raw_body; it never forwards cookies, credentials or headers it reserves. A manifest that uses verify_headers declares contracts revision 2 and needs a Studio that reads it.

Serve it

Run from the directory that holds the module:

# Print the manifest (canonical JSON) and exit.
latent serve --extension ext:ext --manifest

# Serve locally on :8080, unregistered. K is LATENT_SERVE_SECRET or random.
STUDIO_ORIGIN=http://localhost:3000 latent serve --extension ext:ext

A local server keeps its own nonce store and never calls Studio. STUDIO_ORIGIN is required only when the extension has studio-user routes: the user assertions Studio sends name it.

Deployed mode

When LATENT_EXTENSION_VERSION is set, the pod registers itself with Studio (POST /api/extensions/register) and re-registers at half the lease. It needs:

Variable Meaning
LATENT_EXTENSION_VERSION The version, ^[a-z0-9]{1,16}$. The serving-operator sets it.
LATENT_SERVE_SECRET The HMAC key K, shared by every replica of the version.
LATENT_SERVE_PUBLIC_URL The version's Service URL, http://x-<name>-<version>-backend.<namespace>.svc.cluster.local.
AGENT_STUDIO_URL Studio's base URL.
AGENT_STUDIO_API_KEY An lsk_ project key scoped to this extension.
STUDIO_ORIGIN The Studio origin user assertions name.

infra/Dockerfile.extension builds the image. Its entrypoint (infra/latent-extension-entrypoint.sh) runs latent serve --extension "$LATENT_EXTENSION_ENTRY".

Registration failures do not crash the pod:

  • 401 or 403 — the key is wrong or scoped to something else. The process exits non-zero.
  • Anything else — /readyz stays 503 and the pod retries with backoff (1–60 s, full jitter).
  • 409 HmacKeyConflict — the version is live in Studio with a different K. This means its Secret changed under a live version. The pod logs the conflict at error on every retry and stays unready; the other replicas keep serving. Restore the Secret, or ship the next version with its own Secret (x-<name>-<next version>-secrets). Never edit the Secret of a live version.

The pod never deregisters. Its registration lapses with the lease, so one replica's shutdown never takes the version from the others.

Check it: conformance

latent conformance runs the Studio contract checks against a running pod:

LATENT_SERVE_SECRET=<K> latent conformance --url http://extension:8080 \
  --manifest manifest.json --report conformance.json \
  --studio-origin http://localhost:3000

It checks health and readiness, that unsigned, wrongly signed and replayed calls get 401, that the signed GET /v1/manifest equals the extracted manifest, 404 for undeclared names, 422 for invalid bodies, the phase budgets, and every 200 body against its schema. The example calls are reported but do not block, since a CI network cannot reach customer systems. The command exits 0 only when every blocking check passed. --studio-origin must equal the pod's STUDIO_ORIGIN when the extension has studio-user routes. Pass K through the environment rather than --secret, so it stays out of process listings.

Register and promote

CI registers a built version with its manifest and passing report:

latent studio agents register-version --kind extension \
  --name demo-rules --version v1 --image <registry>/<repo>@sha256:<digest> \
  --manifest manifest.json --conformance conformance.json

agent-studio's reusable workflow build-extension.yml runs build, manifest, conformance on an internal Docker network, and this registration.

Registering serves nothing. Promote, inspect and roll back with:

latent studio extensions versions demo-rules
latent studio extensions promote demo-rules v1
latent studio extensions rollout demo-rules
latent studio extensions rollback demo-rules
latent studio extensions clear demo-rules

An extension has one active version and a last known good one; there are no weights. clear leaves no active version, so every required gate falls back to Studio's outage rule.

See also

  • Pod events — events an agent handles inside its serving pod.
  • Serving Agents — the agent pod, HMAC signing and registration.