Status: Runnable
Record a run and observations
Pull and load the exact Service version you deployed, wrap each request in a run, record what each component saw and did, and hand it to a Verifier with verify or submit. Verifiers judge these runs, and every row points back to the Card version and run that produced it. This is the path for application code; to load or query tables directly, use the Bifrost client.
Runs, observations, and retained evidence explains the model behind these calls.
Prerequisites
The declared
support-deskService from Verify your first agent. Its Cards live inexamples/support-desk-app/service/: the Service declares aticketstable withquestionandanswercolumns, bindsno-refund-promise(oneanswerinput) to itself, and bindsticket-complete(oneticketinput) andfollows-policy(oneletterfile) to itsagentcomponent.A credential bound to that Service version, so the server can attribute what it records. Against the local server from Set up Wyrd, the saved settings work as they are, as in the quickstart. Elsewhere, a tenant administrator issues one:
bashwyrd auth issue-key --kind Service --name support-desk --version 1.0.0 --space defaultThe key prints once. Give it to the service as
WYRD_API_KEY, alongsideWYRD_SERVER_URL.The SDK for your language:
pip install "wyrd-ai[otel]",npm install wyrd-ai, or thewyrd-aicrate with itsotelfeature.
1. Pull the bundle
wyrd pull writes the Service and every Card it references, including model weights and datasets, into the directory you name. Do this at build or deploy time, not per request:
wyrd pull --space default --kind Service --name support-desk --version 1.0.0
--output-dir ./.wyrd-bundlePull replaces an existing bundle only once the new one is complete, so a failed pull leaves the previous bundle usable. wyrd get with the same selector prints the Service’s envelope only and writes nothing. The example apps pull from code instead, right after declaring; see step 4 of the quickstart.
2. Load it and start Bifrost and telemetry
Once, at startup:
self.client = client or WyrdClient()
self.state = WyrdState.from_path(BUNDLE, self.client)
self.state.start_bifrost()
# Spans export to Wyrd through the SDK's OTLP exporter; the Agents SDK's
# own traces become spans too, under the request's Run.
self.state.start_telemetry()
set_trace_processors([WyrdAgentsTraceProcessor()])let state = WyrdState::from_path_with(bundle, client.clone())?;
// Starts the buffered writer the ticket rows go through.
state.start_bifrost().await?;// WYRD_SERVER_URL and WYRD_API_KEY, else the settings `wyrd server dev` saved, say which server to use and who the app is.
const client = WyrdClient.connect();
// The declared Service, pulled by `npm run deploy`.
const state = WyrdState.fromPath(BUNDLE_DIR, { client });
await state.startBifrost(); // writes tickets to Bifrost
await state.startTelemetry(); // exports spans to Wyrdfrom_path reads local files only. start_bifrost is the first server call: it connects the writer and fails at startup if the server is unreachable or your credential cannot write observations. Telemetry exports spans started inside a run with that run’s ID. In Python and TypeScript it is state.start_telemetry() (startTelemetry). In Rust it is wyrd_ai::otel::start_telemetry(&state), which returns a Telemetry handle you keep until shutdown; the Rust app starts it when it begins serving, in step 4.
3. Record, verify, and submit
Open a run, then call observe on it. Everything in one run shares a run_id. The app answers a question in a new run of the Service and records the ticket in the tickets table:
run = self.state.run()
with run, tracer.start_as_current_span("support-desk.ask"):
# These headers tie the gateway's record of the call to this Run.
headers = {
"wyrd-run-id": run.run_id,
"wyrd-card-uid": str(self.state.card_ref("agent").uid),
}
model = OpenAIChatCompletionsModel(
self.model, self.gateway.with_options(default_headers=headers)
)
agent = Agent(name="support-agent", model=model)
result = await Runner.run(agent, self.template.replace("{{question}}", question))
answer = str(result.final_output)
run.observe.record(TICKETS, {"question": question, "answer": answer})let agent = self.state.agent(run.alias())?;
let ExecutionRoute::WyrdGateway { deployment } = &agent.card().spec.execution_route else {
return Err(ApiError::internal(
"the Agent does not route through the Wyrd gateway",
));
};
let prompt = &agent.prompt().metadata.prompt;
let rendered = prompt
.render(&[("question", question)])
.map_err(|_| ApiError::internal("the Agent's Prompt does not render"))?;
let mut body = serde_json::to_value(rendered)
.map_err(|_| ApiError::internal("the rendered Prompt does not serialize"))?["body"]
.take();
body["model"] = json!(format!("{deployment}/{}", prompt.model));
let client = self.state.client()?;
// These headers tie the gateway's record of the call to this Run.
let reply = reqwest::Client::new()
.post(format!("{}/v1/chat/completions", client.server_url()))
.bearer_auth(client.access_token().await?.expose())
.header("wyrd-run-id", run.run_id().as_str())
.header("wyrd-card-uid", run.subject_uid().as_str())
.json(&body)
.timeout(ANSWER_TIMEOUT)
.send()
.await
.map_err(|_| ApiError::internal("the Wyrd gateway call failed"))?;
let status = reply.status();
let reply: Option<Value> = reply.json().await.ok();
if !status.is_success() {
return Err(ApiError::refusal(status, reply.as_ref()));
}
let reply =
reply.ok_or_else(|| ApiError::internal("the gateway reply could not be read"))?;
let answer = reply["choices"][0]["message"]["content"]
.as_str()
.ok_or_else(|| ApiError::internal("the gateway reply carries no text"))?
.to_owned();
run.observe()
.record(
"vala.datasets.tickets",
&json!({ "question": question, "answer": answer }),
)
.await?;const wyrdRun = support.state.run();
// Spans started inside the scope carry the Run's ID.
return wyrdRun.scope(async () => {
const text = await answer(support.gateway, support.state, wyrdRun, question);
await wyrdRun.observe.record(TICKETS, { question, answer: text });
return check(wyrdRun, text);
});Then it runs the two model-free Verifiers and waits for their verdicts. no-refund-promise is bound to the Service, so it runs on the run itself. ticket-complete is bound to the agent, so it runs on for_card("agent"), the run’s agent view, which shares the same run_id:
return {
"no-refund-promise": run.observe.verify("no-refund-promise", answer=ticket["answer"]),
"ticket-complete": run.for_card("agent").observe.verify(
"ticket-complete", ticket=ticket
),
}let run = desk.state.run();
let agent = run.for_card("agent")?;
let work = async {
let answer = desk.answer(&agent, &body.question).await?;
let promise = run
.observe()
.verify(
"no-refund-promise",
Inputs::named([("answer", Input::from(json!(answer)))]),
)
.await?;
let ticket = json!({
"question": body.question,
"answer": answer,
"order_id": body.order_id,
});
let complete = agent
.observe()
.verify(
"ticket-complete",
Inputs::named([("ticket", Input::from(ticket))]),
)
.await?;
Ok::<_, ApiError>((answer, promise, complete))
};
let (answer, promise, complete) = run
.scope(work.instrument(tracing::info_span!("support-desk.ask")))
.await?;const ticket = { question, answer: text, order_id: orderId ?? null };
const [promise, complete] = await Promise.all([
wyrdRun.observe.verify("no-refund-promise", { answer: text }),
wyrdRun.forCard("agent").observe.verify("ticket-complete", { ticket }),
]);follows-policy calls a model judge, so the app queues it on the agent’s letter instead of waiting:
execution = run.for_card("agent").observe.submit(
"follows-policy", letter=verify.File(letter.encode(), filename="letter.md")
)
assert isinstance(execution, verify.Execution) # Run evidence always queues onelet submission = run
.observe()
.submit("follows-policy", letter_inputs(&letter))
.await?;const letter = verify.File.bytes(new TextEncoder().encode(text), { filename: "letter.md" });
const inputs = { letter };
const submitted = await wyrdRun.forCard("agent").observe.submit("follows-policy", inputs);
if (submitted.kind !== "queued") throw new Error("follows-policy judges Run evidence, so submit queues it");| Call | Does | Returns |
|---|---|---|
record(table, row) | Queues one row for a vala.datasets table | Nothing; the row is stored at the next flush |
verify(name, inputs) | Runs a Verifier and waits for it | The verdict (passed, failed, or inconclusive) and one result per requirement |
submit(name, inputs) | Queues a Verifier on the server | An execution whose id reads the result later |
verify and submit take a Verifier bound to the Card of the run or view you call them on, and one input per slot that Verifier declares: answer for no-refund-promise, ticket for ticket-complete, and the letter file for follows-policy. verify.File fills a file slot. A failed verdict is a normal return, not an error.
Read a submitted result back from any process, by its execution ID alone:
# Any process can pick a check up from its execution ID alone.
state = verify.Execution.connect(execution_id, request.app.state.desk.client).status()let state = Execution::connect_with(id, desk.state.client()?)
.await?
.status()
.await?;const execution = await verify.Execution.connect(executionId, { client: support.client });
const { status, error, result } = await execution.status();status() returns the execution’s state without waiting; wait on the same execution blocks until it is final, up to the timeout you pass.
4. Shut down cleanly
Queued rows and spans are not stored yet. Shut down once when the process stops:
app.state.desk = Desk()
yield
# Flush buffered tickets and spans to Wyrd before exiting.
app.state.desk.state.shutdown()let telemetry = start_telemetry(&self.state)?;
let state = self.state.clone();
let served = axum::serve(listener, self.router())
.with_graceful_shutdown(stop)
.await;
// Flush spans first, then drain buffered tickets, before exiting.
telemetry.shutdown()?;
state.shutdown().await?;// Flush every span and row before exiting: shutdown flushes the provider startTelemetry installed, then drains the writer.
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, async () => {
server.close();
await state.shutdown();
process.exit(0);
});
}shutdown sends everything still queued and returns once the server has it. A process killed without it can lose its last rows and spans. flush does the same without closing the writer, for tests and batch jobs. In Python and TypeScript, both first flush the tracer provider start_telemetry installed, and still drain the writer if that flush fails. In Rust, WyrdState::shutdown drains only the writer: call shutdown() on the Telemetry handle first, as above.
5. Read a run back
Every row a run produced carries its run_id, so one query joins the ticket to the request span, the gateway call, and each verdict:
SQL = """
SELECT t.run_id, t.question, t.answer, s.trace_id, g.call_id, r.execution_id, r.verdict
FROM vala.datasets.tickets t
JOIN vala.traces.spans s ON s.run_id = t.run_id AND s.name = 'support-desk.ask'
JOIN vala.gateway.calls g ON g.run_id = t.run_id
JOIN vala.verification.results r ON r.run_id = t.run_id
WHERE t.run_id = $1
"""/**
* Read one Run's evidence back through Bifrost. Every row a Run produced
* carries its `run_id`, so one join links the ticket to the agent's
* span, its gateway call, and each verdict.
*
* npm run evidence -- <run_id>
*/
import { Bifrost, type WyrdClient } from "wyrd-ai";
import { z } from "zod";
import { TICKETS } from "./wyrd.ts";
const Evidence = z.object({
run_id: z.string(),
question: z.string(),
answer: z.string(),
span: z.string(),
trace_id: z.string(),
call_id: z.string(),
verdict: z.string(),
execution_id: z.string(),
});
/** The joined evidence of Run `runId`, read as `client` (the ambient client by default). */
export async function evidence(runId: string, client?: WyrdClient) {
const bifrost = await Bifrost.connect({ client });
try {
return await bifrost.sql(
`SELECT c.run_id, c.question, c.answer, s.name AS span, encode(s.trace_id, 'hex') AS trace_id, g.call_id, r.verdict, r.execution_id
FROM ${TICKETS} c
JOIN vala.traces.spans s ON s.run_id = c.run_id AND s.name LIKE 'invoke_agent %'
JOIN vala.gateway.calls g ON g.run_id = c.run_id
JOIN vala.verification.results r ON r.run_id = c.run_id
WHERE c.run_id = $1`,
[runId],
Evidence,
);
} finally {
await bifrost.shutdown();
}
}
if (import.meta.main) {
const runId = process.argv[2];
if (!runId) throw new Error("usage: npm run evidence -- <run_id>");
console.table(await evidence(runId));
}
The Rust app runs the same join in its evidence function. Bifrost().trace_timeline(trace_id) (traceTimeline in TypeScript) returns one trace’s spans with their parent ids, status, and attributes.
Rows become queryable a short time after flush or shutdown returns. An empty result right after a flush is not proof they are missing; retry first.
What can go wrong
| Error code | When | Cause | Fix |
|---|---|---|---|
WYRD_SDK_400_BIFROST_NOT_STARTED | At the call | observe before start_bifrost | Start Bifrost at startup |
WYRD_SDK_409_BIFROST_CLOSED | At the call | observe after shutdown | Shut down only at process exit |
WYRD_SDK_404_UNKNOWN_ALIAS | At the call | run or for_card with an alias not in the bundle | Use an alias from the Service’s components |
WYRD_SDK_400_INVALID_OBSERVATION | At the call | Empty or nested drift features, or record to a table outside vala.datasets | Send flat values; record only to your own tables |
WYRD_CLIENT_429_QUEUE_FULL | At the call | The server is unreachable or slow and the queue filled | Decide whether to drop or retry. The SDK never blocks your request |
WYRD_VALA_404_BIFROST_TABLE_NOT_FOUND | At record | The table was never registered | Declare it in spec.tables or register it |
WYRD_VALA_403_BIFROST_CARD_SCOPE | At flush or shutdown | The credential is bound to a different Card than the one recorded about | Use the key issued for this Service version |
WYRD_PERMISSION_403_DENIED_RBAC | At flush or shutdown | The credential lacks bifrost_record:write | Use a workload credential |
WYRD_SDK_409_TELEMETRY_PROVIDER_EXISTS | At start_telemetry | The application already installed a tracer provider | Don’t call start_telemetry; keep your own provider |
WYRD_SDK_504_TELEMETRY_FLUSH_TIMEOUT | At flush or shutdown | The tracer provider start_telemetry installed did not flush in time | Check that the server is reachable. The writer still drained; details.native_code names its failure, if any |
WYRD_SDK_503_TELEMETRY_FLUSH_FAILED | At flush or shutdown | That provider failed to flush. In TypeScript this includes a refused span export | Check that the credential may write telemetry. details.native_code names any writer failure |
Card-scope and permission refusals arrive at flush or shutdown, not at the observe call, because the server checks them when the batch lands. The Python OpenTelemetry SDK logs a refused span export rather than failing its flush, so look for it in the log. Call flush in a smoke test after deploying so a wrong key fails there instead of silently at exit.
Last updated