@m8t-stack/platform-sdk
v0.2.7
Published
Typed client + service-principal auth for the m8t gateway; re-exports @m8t-stack/api-contract.
Readme
@m8t-stack/platform-sdk
📦 The one import for integrating with an m8t gateway:
npm i @m8t-stack/platform-sdk
A thin, typed client for the m8t gateway HTTP API. Acquires a service-principal token from a pluggable Azure credential (default DefaultAzureCredential, secretless via Managed Identity), invokes workers, streams replies, and re-exports all of @m8t-stack/api-contract so you import types from one place.
Quick start
import { PlatformClient } from "@m8t-stack/platform-sdk";
const client = new PlatformClient({
gatewayUrl: "https://your-gateway.example.com",
audience: "api://<gateway-app-client-id>",
});
// Identify the end user you are acting for. One key per person: the gateway
// derives a separate conversation per key, so two keys never see each other's
// history. Required — a call with no end user to attribute it to is refused.
const endUserKey = "end-user-1";
const { id } = await client.createConversation({ agentName: "stacey", endUserKey });
const stream = client.invoke({ agentName: "stacey", conversationId: id, message: "Hello!", endUserKey });
for await (const ev of stream) {
if (ev.type === "text") process.stdout.write(ev.delta);
}
// The gateway is the record — read history back rather than keeping a copy.
const { messages } = await client.listMessages({ conversationId: id, endUserKey });An install-offer event is a non-interactive, contextual follow-on emitted
after the worker's substantive answer. Its directive.args.actions contains
one to three concrete actions; the consuming product owns the surrounding
install copy, trust claims, link, and presentation frequency. Presented offers
are also reconstructed on listMessages as message.installOffer with the
durable createdAt timestamp.
Teaching
Teaching is an explicitly authorized, conversation-surface-scoped flow.
The gateway derives the Brain target and every transcript boundary; clients
send only the redacted surface/episode IDs and the same per-call endUserKey:
const conversation = await client.createConversation({ agentName: "ezra", endUserKey });
const episode = await client.startTeachingEpisode({
surfaceId: conversation.surfaceId,
endUserKey,
});
await client.invoke({
agentName: conversation.agent,
conversationId: conversation.id,
message: "The correction I want Ezra to understand…",
endUserKey,
teaching: { surfaceId: episode.surfaceId, episodeId: episode.episodeId },
}).text();
const awaiting = await client.beginTeachingSummary({
surfaceId: episode.surfaceId,
episodeId: episode.episodeId,
endUserKey,
});
await client.confirmTeachingEpisode({
surfaceId: awaiting.surfaceId,
episodeId: awaiting.episodeId,
summaryVersion: awaiting.summary!.version,
summaryHash: awaiting.summary!.hash,
endUserKey,
});Confirmation queues a human-reviewed Brain proposal; it does not mean the
lesson has merged. Use listTeachingEpisodes to reconstruct active and terminal
ranges after reload. resumeTeachingEpisode returns an awaiting summary to the
conversation for correction, and cancelTeachingEpisode closes the range
without creating evidence or a proposal.
The default credential resolves a token for ${audience}/.default. The token's app role must include Agents.Invoke (see the gateway operator setup). Pass any @azure/core-auth TokenCredential as credential to override.
Decision turns carry no text. When a worker asks the end user to choose, the
gateway emits a directive event instead of a reply — a turn that yields only
a directive is complete, not empty. A consumer that handles just text shows
the person nothing, so render the decision (or relay it) as well:
for await (const ev of stream) {
if (ev.type === "text") process.stdout.write(ev.delta);
// Only a PENDING card is a question. The acknowledgement of an answer arrives
// on this same arm in state `selected` — render that unguarded and you ask
// the person the question they just answered.
else if (ev.type === "directive" && ev.directive.state.status === "pending") {
const { title, options } = ev.directive.args; // 2–4 options, each { label, detail }
console.log(title, options.map((o) => o.label));
}
}Answering a card. sendDecisionResult settles the pending call with the
option the person chose. Same route, same delegated identity, and the same
InvokeStream back — the gateway acknowledges the selection as a directive
event in state selected, then streams the worker's follow-up turn, which may
itself be another card:
const reply = client.sendDecisionResult({
agentName, conversationId, endUserKey,
directive: ev.directive, // the one you were just handed
optionIndex: 0,
});
for await (const next of reply) { /* same loop as above */ }The answer is sent lazily — on first iteration, exactly like invoke. Drop
the returned stream on the floor and the request is never made: the card stays
pending and nothing tells you. If you do not want the reply, still drain it
(await reply.text()).
An optionIndex the directive does not offer throws RangeError before any
request. Refusals arrive as a PlatformApiError; read the reason with
decisionRefusalReason(error):
| Reason | Meaning |
|---|---|
| decision_not_pending | that call is already settled — see below |
| invalid_decision_agent | decisions are a prompt-worker surface |
| invalid_decision_result | the payload or option was not readable |
Talking past a card dismisses it, terminally. If the person types an ordinary
message while a card is pending, invoke carries only text — the gateway records
the card as dismissed, the worker answers the message instead, and a later
sendDecisionResult for that callId is refused with decision_not_pending.
That is the intended escape hatch, not an error: someone who ignores a card
should not be stuck behind it. Design for both exits.
ev.directive.callId correlates the card across turns; ev.source is "text"
or "voice". A frame this contract version cannot read is skipped rather than
thrown, so an older SDK degrades to a text-only turn.
End-user keys. endUserKey may contain A-Z a-z 0-9 . _ -, 1–200 characters. A UUID, an opaque session id, or a hash of your own identifier all fit; encode anything richer. It is an identity assertion, so use a key your system controls — never one an end user can choose.
See versioning & deprecation policy. Full runnable example: examples/stream-reply.ts.
