Skip to main content
Most apps run prompts in their own code — render the prompt, call your SDK. That’s Running prompts, and needs nothing here. This page is for the other case: letting AgentMark Cloud run a prompt for you — the Dashboard Run button and Cloud-driven experiments. AgentMark dispatches those to a runner you deploy; Client setup wires and serves the runner. The runner calls your SDK through one small function — an executor. This page is that function for your SDK: copy the one that matches and drop it into your runner.

Write an executor

createExecutor takes a pair of handlers (text / object). Each receives formatted — the neutral rendered prompt — and returns { text | object, usage }. That’s the whole contract; Client setup handles wiring it into a runner and serving it.
import { createExecutor } from "@agentmark-ai/prompt-core";

export const executor = createExecutor({
  name: "bedrock-converse",
  // `formatted.text_config.model_name` and `.messages` are typed, no cast.
  text: async (formatted) => {
    const res = await bedrock.converse({ modelId: formatted.text_config.model_name, messages: formatted.messages });
    return { text: res.outputText, usage: res.usage };
  },
  object: async (formatted) => {
    const res = await bedrock.converse({ modelId: formatted.text_config.model_name, messages: formatted.messages });
    return { object: JSON.parse(res.outputText), usage: res.usage };
  },
});

Reference setups

Complete, copy-paste executors for the SDKs teams reach for most. Each wraps the SDK directly — there’s no AgentMark adapter package to install or version. Copy the closest one, adjust the model mapping, and you’re done. Every one takes the neutral render and returns { text | object, usage }.

Vercel AI SDK

Wraps the ai package’s generateText / streamText (and generateObject for structured output), with both one-shot and streaming text paths:
import { createExecutor } from "@agentmark-ai/prompt-core";
import { generateText, streamText, generateObject, jsonSchema } from "ai";
import { openai } from "@ai-sdk/openai";

// Minimal model resolution — strip an optional "openai/" prefix. Swap in your
// own provider map (anthropic, google, …) keyed off the model name as needed.
const model = (name: string) => openai(name.replace(/^openai\//, ""));

export const executor = createExecutor({
  name: "vercel-ai-sdk",
  text: async (formatted, ctx) => {
    const { text, usage } = await generateText({
      model: model(formatted.text_config.model_name),
      messages: formatted.messages,
      abortSignal: ctx.signal,
    });
    // AI SDK v5 usage is already canonical ({ inputTokens, outputTokens }).
    // On v4 it's { promptTokens, completionTokens } — rename those two.
    return { text, usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } };
  },
  streamText: async function* (formatted, ctx) {
    const result = streamText({
      model: model(formatted.text_config.model_name),
      messages: formatted.messages,
      abortSignal: ctx.signal,
    });
    // Consume `fullStream`, not `textStream` — a failed model call (bad API
    // key, rate limit, unknown model) surfaces ONLY as an `error` part on
    // fullStream. textStream just ends silently, and `await result.usage`
    // then rejects with the AI SDK's generic "No output generated. Check the
    // stream for errors." — hiding the real cause from the dashboard.
    for await (const part of result.fullStream) {
      if (part.type === "error") throw part.error; // builder emits it as a terminal error event
      if (part.type === "text-delta") yield { type: "text-delta", text: part.text };
      if (part.type === "finish") {
        const usage = part.totalUsage ?? part.usage;
        yield {
          type: "finish",
          reason: part.finishReason ?? "stop",
          usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
        };
      }
    }
  },
  object: async (formatted, ctx) => {
    const { object, usage } = await generateObject({
      model: model(formatted.object_config.model_name),
      messages: formatted.messages,
      schema: jsonSchema(formatted.object_config.schema),
      abortSignal: ctx.signal,
    });
    return { object, usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } };
  },
});

OpenAI (raw SDK)

The official OpenAI SDK’s chat.completions.create. Messages map straight through; in Python, formatted is a Pydantic model so model_dump the messages:
import { createExecutor } from "@agentmark-ai/prompt-core";
import OpenAI from "openai";

const openai = new OpenAI();
const model = (name: string) => name.replace(/^openai\//, "");

export const executor = createExecutor({
  name: "openai",
  text: async (formatted, ctx) => {
    const res = await openai.chat.completions.create(
      { model: model(formatted.text_config.model_name), messages: formatted.messages },
      { signal: ctx.signal },
    );
    return {
      text: res.choices[0].message.content ?? "",
      finishReason: res.choices[0].finish_reason,
      usage: { inputTokens: res.usage?.prompt_tokens ?? 0, outputTokens: res.usage?.completion_tokens ?? 0 },
    };
  },
  object: async (formatted, ctx) => {
    const res = await openai.chat.completions.create(
      {
        model: model(formatted.object_config.model_name),
        messages: formatted.messages,
        response_format: {
          type: "json_schema",
          json_schema: { name: "response", schema: formatted.object_config.schema, strict: true },
        },
      },
      { signal: ctx.signal },
    );
    return {
      object: JSON.parse(res.choices[0].message.content ?? "{}"),
      usage: { inputTokens: res.usage?.prompt_tokens ?? 0, outputTokens: res.usage?.completion_tokens ?? 0 },
    };
  },
});

Anthropic (raw SDK)

The @anthropic-ai/sdk messages.create. Anthropic takes system as a top-level field and requires max_tokens, so split the system message out of the neutral render:
import { createExecutor } from "@agentmark-ai/prompt-core";
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic();
const model = (name: string) => name.replace(/^anthropic\//, "");

export const executor = createExecutor({
  name: "anthropic",
  text: async (formatted, ctx) => {
    const system = formatted.messages.filter((m) => m.role === "system").map((m) => m.content).join("\n");
    const messages = formatted.messages.filter((m) => m.role !== "system");
    const res = await anthropic.messages.create(
      {
        model: model(formatted.text_config.model_name),
        max_tokens: formatted.text_config.max_tokens ?? 1024,
        system,
        messages: messages as Anthropic.MessageParam[],
      },
      { signal: ctx.signal },
    );
    const text = res.content.map((b) => (b.type === "text" ? b.text : "")).join("");
    return { text, usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens } };
  },
});

Amazon Bedrock (Python)

Bedrock’s invoke_model takes a different request shape: anthropic_version lives in the body, max_tokens is required, and the model ID is a full cross-region inference profile ID — not the short alias in the prompt’s model_name. Map it explicitly. The runner automatically stamps gen_ai.operation.name = "chat" and the config alias on the span, so the Requests view and cost attribution work with no extra code. To surface the full inference profile ID in the dashboard instead of the alias, override gen_ai.request.model on the span after your call (set_attribute is last-write-wins):
import json
import boto3
from agentmark.prompt_core import create_executor, ExecutorTextResult, UsageData

bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

# Map the short config alias to the full cross-region inference profile ID.
MODEL_MAP: dict[str, str] = {
    "us.anthropic.claude-opus-4-8": "us.anthropic.claude-opus-4-8-20251101-v1:0",
    "us.anthropic.claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6-20251001-v1:0",
    # add more as needed
}

def _text(formatted, ctx) -> ExecutorTextResult:
    model_id = MODEL_MAP.get(formatted.text_config.model_name, formatted.text_config.model_name)

    body = {
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": formatted.text_config.max_tokens or 1024,
        "messages": [m.model_dump(exclude_none=True) for m in formatted.messages],
    }
    response = bedrock.invoke_model(
        modelId=model_id, body=json.dumps(body),
        contentType="application/json", accept="application/json",
    )
    payload = json.loads(response["body"].read())
    text = "".join(b["text"] for b in payload["content"] if b.get("type") == "text")
    usage = payload.get("usage", {})

    # Show what actually ran (the resolved profile ID), not the config alias.
    span = (ctx.extra or {}).get("span")
    if span:
        span.set_attribute("gen_ai.request.model", model_id)

    return ExecutorTextResult(
        text=text,
        usage=UsageData(input_tokens=usage.get("input_tokens", 0), output_tokens=usage.get("output_tokens", 0)),
    )

executor = create_executor(name="bedrock", text=_text)

Agent frameworks (Pydantic AI, Mastra, Claude Agent SDK)

Agent frameworks follow the identical shape; the only difference is that your handler runs an agent loop instead of a single completion. Feed the render’s messages into your agent, run it, and return its final output plus token usage:
import { createExecutor } from "@agentmark-ai/prompt-core";

export const executor = createExecutor({
  name: "my-agent-framework",
  text: async (formatted, ctx) => {
    // Run your agent (Pydantic AI Agent.run, a Mastra Agent, Claude's query(), …)
    // with formatted.messages, honoring ctx.signal for cancellation.
    const result = await runMyAgent(formatted.messages, { signal: ctx.signal });
    return { text: result.output, usage: { inputTokens: result.inputTokens, outputTokens: result.outputTokens } };
  },
});
For token-by-token output and tool-call events, use a streaming handler and yield text-delta / tool-call / tool-result events as the agent emits them. See Streaming SDKs.

Streaming SDKs

If your SDK streams (e.g. Bedrock ConverseStream), use the streaming handlers instead of buffering. They yield the same content events (text-delta, tool-call, …) and report usage plus the finish reason on a finish event you yield; the builder emits the single terminal finish for you:
const executor = createExecutor({
  name: "bedrock-converse-stream",
  streamText: async function* (formatted) {
    for await (const chunk of bedrock.converseStream({ modelId, messages: formatted.messages })) {
      if (chunk.delta) yield { type: "text-delta", text: chunk.delta };
      // Report usage + provider stop reason on a finish event — the builder
      // captures it and emits the one terminal finish.
      else if (chunk.usage) yield { type: "finish", reason: chunk.stopReason ?? "stop", usage: chunk.usage };
    }
  },
});
Streaming object handlers yield object-delta / object-final events (ObjectDeltaEvent / ObjectFinalEvent in Python) and a finish carrying usage. If your SDK only streams cumulative partials (no explicit final), the builder uses the last delta as the resolved value, so AgentMark Cloud always receives a complete object.

Validate your executor

Run the conformance suite. One call confirms your executor emits a protocol-correct stream for every kind, streaming and one-shot, including the error path — errorInput is a malformed render your handler rejects before any network call:
import { runExecutorConformance } from "@agentmark-ai/prompt-core";

await runExecutorConformance(executor, {
  text: { messages: [{ role: "user", content: "hello" }], text_config: { model_name: "openai/gpt-4o" } },
  object: { messages: [{ role: "user", content: "give me JSON" }], object_config: { model_name: "openai/gpt-4o" } },
  errorInput: { messages: null },
});
Unless you pin ctx, the suite runs your executor twice, once streaming and once one-shot, so if you supply both a one-shot and a streaming handler, both branches are validated (a broken one-shot path won’t hide behind a working stream).
Provider-specific parameter mapping (tool wiring, custom settings, full request control) also lives in your executor: its handlers receive the neutral render and build the exact request your SDK expects. See the resolve-by-name tools pattern for wiring frontmatter tool names to implementations.

Model names vs provider model IDs

formatted.text_config.model_name is the prompt’s model_name verbatim: a registry ID in provider/model form. Your executor owns the translation to whatever ID your SDK expects. Two common shapes: Strip the provider prefix when the registry ID is your SDK’s model ID — the usual case (openai/gpt-4ogpt-4o):
const modelId = formatted.text_config.model_name.replace(/^[^/]+\//, "");
Map names explicitly when your prompts declare one provider’s names but your executor calls another (e.g. prompts on anthropic/claude-sonnet-4-6, production on Bedrock). Keep the dict in the executor so it’s versioned with the code, and fail loudly on unmapped names instead of passing them through (an unmapped name otherwise surfaces as a confusing provider-side 404):
const BEDROCK_IDS: Record<string, string> = {
  "anthropic/claude-opus-4-6": "global.anthropic.claude-opus-4-6-v1",
  "anthropic/claude-sonnet-4-6": "global.anthropic.claude-sonnet-4-6",
  "anthropic/claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
};
const modelId = BEDROCK_IDS[formatted.text_config.model_name];
if (!modelId) throw new Error(`No Bedrock mapping for ${formatted.text_config.model_name}`);
Either way, declare the names your prompts use in builtInModels (a non-empty list is an allowlist). npx @agentmark-ai/cli pull-models --provider bedrock lists the registry’s Bedrock IDs.

Migrating from the adapter packages

The SDK-specific adapter packages (@agentmark-ai/ai-sdk-v4-adapter, @agentmark-ai/ai-sdk-v5-adapter, @agentmark-ai/mastra-v0-adapter, agentmark-pydantic-ai-v0, and the Claude Agent SDK adapters) are removed. Everything they did is covered without them:
  • If you used an adapter’s model registry + format() to call your SDK, switch to the neutral renderformat() gives you { messages, ...config } and you make the SDK call yourself, in your own app. See Running prompts.
  • If you used an adapter’s webhook handler (VercelAdapterWebhookHandler et al.) for agentmark dev or managed runs, replace it with an executor (this page) wired into a runner and served — see Client setup.
In TypeScript, whatever the adapter exported your client as, the replacement is one import: import { createAgentMark } from "@agentmark-ai/prompt-core". The Python adapter packages have no shim; replace them with agentmark-prompt-core directly (from agentmark.prompt_core import create_agentmark, create_executor, create_webhook_runner).

Have questions?

Reach out any time: