@band-ai/band-sdk-core
v6.1.1
Published
Shared Band event-payload validation
Keywords
Readme
@band-ai/band-sdk-core
Shared Band event-payload validation, memory-taxonomy, and inbound-delivery runtime state, compiled from Rust to WebAssembly for Node. Every function is synchronous, pure computation — no network, filesystem, or logging.
band-sdk-core (Rust) --> @band-ai/band-sdk-core (this package, npm) --> band-sdk-typescriptIf you're using band-sdk-typescript,
it already depends on this package — you don't need to install it
yourself. Install it directly only if you're calling into it without that
SDK. Full picture: repository architecture.
Install
npm install @band-ai/band-sdk-coreThe package is Node CommonJS with an eager WASM load — there is no init
step, and no Rust or wasm-pack toolchain needed to install. Both
require() and ESM import() resolve it at the package root.
Quickstart
const { validateEventPayload } = require("@band-ai/band-sdk-core");
try {
const payload = validateEventPayload("room_deleted", { id: "room-1" });
} catch (err) {
for (const { path, code, message } of err.issues) {
console.error(`${path}: ${code} - ${message}`);
}
}validateEventPayload is the platform's inbound WebSocket payload policy:
normalize a well-formed payload, or throw with every violation reported at
once, never just the first.
Public surface
validateEventPayload(eventType, raw, traceContext?)
eventType is the platform event name ("message_created",
"agent.control", …). raw is a plain JS value. Success returns the
normalized payload (event_created returns the input unchanged). Failure
throws a native Error with .issues ({path, code, message} objects)
and .traceContext — not a message-only error. There is no custom
exception class.
Delivery-state runtime classes
ClaimRegistry, RetryTracker, ParticipantRoster, and SubscriptionTracker are the
inbound-delivery runtime state classes — lifecycle and design decisions
live as rustdoc on each type in crates/core/src/runtime/.
A participant is any plain object; add/setAll read id, name,
type, handle, description from it and list() returns objects with
exactly those five keys. id is required and must be a string; the other
four are each a string or null/absent. setAll takes an optional
traceContext and throws — leaving the roster unchanged, with .issues
and .traceContext attached like validateEventPayload's error — if its
snapshot names the same id twice. RetryTracker's maxTracked must be
at least 1; 0 throws. ClaimRegistry's maxCompleted must be at
least 1 too — 0 also throws.
ClaimRegistry keeps one terminal intent per message until
rememberCompleted or matching confirmSettlement: rememberAckPending records a processed write and
rememberFailedAckPending a failed one carrying its error. The first
intent wins; a different one throws — leaving the entry unchanged, with
.issues and .traceContext attached — and an identical repeat is a
no-op. pendingAck returns {status, error?, parked} or undefined.
parkAck takes a ParkedReason wire name, parkedReasons() lists them,
and any other value throws a plain Error.
SubscriptionTracker provides synchronous, transport-independent decisions for
agent-topic joins and the two-topic room subscription transaction. Its opaque
tickets are JavaScript bigint values and must be supplied when recording a
completion. Failed rollbacks and failed or unknown leaves require explicit
reconciliation before a fresh claim is allowed.
SubscriptionTracker lifecycle
import { SubscriptionTracker } from "@band-ai/band-sdk-core";
const tracker = new SubscriptionTracker();
const ticket = tracker.beginRoomSubscribe("room-1");
if (ticket === undefined) throw new Error("room is not claimable");
switch (tracker.recordRoomParticipantsJoinFailed("room-1", ticket, false)) {
case "rollback_failed":
if (tracker.roomStatus("room-1") !== "needs_reconciliation") {
throw new Error("rollback state was not retained");
}
tracker.acknowledgeRoomReconciled("room-1");
const retryTicket = tracker.beginRoomSubscribe("room-1");
if (retryTicket === undefined) throw new Error("reconciled room is not claimable");
if (tracker.recordBothRoomTopicsJoined("room-1", retryTicket) !== "subscribed") {
throw new Error("room did not subscribe");
}
const leaveTicket = tracker.unsubscribeRoom("room-1");
if (leaveTicket === undefined || !tracker.markRoomLeaveComplete("room-1", leaveTicket, "left")) {
throw new Error("room did not leave");
}
break;
case "subscribed":
case "join_failed":
case "rolled_back":
case "stale":
break;
}DecisionRegistry lifecycle
DecisionRegistry tracks chat-mediated asks: whoever claims one first owns
its outcome. Every registration returns [token, ticket], with ticket a
bigint, and later calls name that exact registration, so a stale holder
gets "stale" instead of acting on a newer ask that reused the token.
maxPending must be an integer of at least 1, or omitted/null for
unbounded; seed is an optional bigint. cancelAll/cancelRoom return a
plain { unclaimed, claimed } object. isAuthorizedSender takes an array,
a Set, or null.
import { DecisionRegistry, isAuthorizedSender } from "@band-ai/band-sdk-core";
const registry = new DecisionRegistry(10);
const registration = registry.registerMinted("room-1");
if (registration === undefined) throw new Error("tickets exhausted");
const [token, ticket] = registration;
if (registry.tryClaim(token, ticket) !== "claimed") throw new Error("not claimed");
if (registry.tryClaim(token, ticket) !== "already_claimed") throw new Error("claimed twice");
if (registry.cancelRoom("room-1").claimed[0] !== token) throw new Error("claim not reported");
if (!isAuthorizedSender(new Set(["alice"]), "alice")) throw new Error("alice refused");Session — WebSocket reconnect state machine
Session/SessionPolicy are a sans-io session state machine plus
reconnect backoff/jitter policy; classifyClose/classifyUpgrade
classify a WebSocket close code or HTTP upgrade-rejection status.
Session never sleeps, connects, or closes a socket itself — the caller
drives its own transport and reports what happened through onConnected/
onSocketClose/onUpgradeRejected/onSupersede. Its epoch tickets are
JavaScript bigint values, matching SubscriptionTracker's. Decisions
live as rustdoc in crates/core/src/runtime/session.rs.
Session lifecycle
import { Session, SessionPolicy } from "@band-ai/band-sdk-core";
const session = new Session(SessionPolicy.default());
const epoch = session.beginAttempt(0.0);
if (epoch === undefined) throw new Error("fresh session must yield an epoch");
const connected = session.onConnected(epoch, 0.0);
if (connected.state !== "up") throw new Error("expected up");
const disconnected = session.onSocketClose(epoch, 5.0, 1006, 0.5);
if (disconnected.state !== "reconnecting") throw new Error("expected reconnecting");
if (disconnected.retryAfterS === undefined) throw new Error("expected a retry delay");validateMemoryTypeForSystem(system, type, traceContext?)
Validates the canonical memory taxonomy — design decisions live as
rustdoc in crates/core/src/memory.rs.
system and type are wire-name strings (MemorySystem/MemoryType in
index.d.ts are string-literal types, not classes — there is no runtime
enum on this boundary, matching how EventType stays a plain string in
validateEventPayload). Returns void on success. Failure throws a
native Error with the same .issues/.traceContext contract as
validateEventPayload — an unrecognized system and an unrecognized
type are independent issues, both reported when both strings are
invalid. See "Enumerating memory-taxonomy values" below for listing each
enum's members without introducing one.
Enumerating memory-taxonomy values
memorySystems(), memoryTypes(), memorySegments(), memoryStoreScopes(),
memoryListScopes(), and memoryStatuses() each return that enum's wire
names as a string[], in declaration order — runtime enumeration without
introducing a class, matching MemorySegment/MemoryStoreScope/
MemoryListScope/MemoryStatus as string-literal types in index.d.ts.
import { memorySystems, memoryStatuses } from "@band-ai/band-sdk-core";
if (!memorySystems().includes("sensory")) {
throw new Error("memorySystems() missing sensory");
}
if (!memoryStatuses().includes("active")) {
throw new Error("memoryStatuses() missing active");
}One-shot delivery lifecycle
evaluateDeliveryEvent, evaluateNextMessage, evaluateDrainCandidate, and
evaluateAdapterResult are the stateless one-shot delivery lifecycle
decisions — design decisions live as rustdoc in
crates/core/src/runtime/delivery.rs. Each returns a plain tagged object;
there is no cross-invocation state, so there is no class here to construct.
Decision fields stay snake_case (message-wire vocabulary: room_id,
sender_id, sender_type, …), unlike this module's own camelCase
function/argument names, so the same shape returns identically from both
bindings.
evaluateDeliveryEvent(eventType, roomId, payload, agentId, traceContext?)
routes one inbound event: "ignored" for an unrecognized or out-of-scope
eventType; "cleanup" for room_removed/room_deleted; "skip_self" or
"invocation" for message_created, depending on whether the sender is
this agent (sender_type === "Agent" and sender_id === agentId).
message_created delegates to validateEventPayload for payload-shape
validation, so a malformed payload throws the same Error, and
additionally rejects an empty payload.id — a message with no identity
cannot be claimed or acknowledged. room_removed/room_deleted need no
payload shape and read id straight off the raw payload.
roomId resolves from the caller-supplied roomId first, else the payload's
own chat_room_id (message events) or id (room events). An empty string
counts as absent at both steps. Unresolvable throws an Error with one issue
on path room_id: code missing when the fallback field is absent or empty,
wrong_type when it is present but not a string.
evaluateNextMessage(triggeringMessageId, nextMessageId) compares the
triggering message against the platform's authoritative "next open message"
for a room (already fetched by the caller): "no_pending",
"already_processed", or "ready_to_claim".
evaluateDrainCandidate(candidate, seenIds, agentId) classifies one
already-fetched drain candidate — candidate is {id, sender_id,
sender_type}, or null/undefined when the fetch returned nothing:
"no_candidate", "self_echo" (checked before the snapshot, so an echo
never halts a drain), "out_of_snapshot" (stop), or "drain" (continue).
The caller's own bounded or unbounded loop owns the cap; this function
classifies one candidate per call.
evaluateAdapterResult(roomId, messageId, succeeded) maps an adapter
outcome to "processed" or "failed".
isSelfEcho(senderId, senderType, agentId) is the one definition of
"self echo" (senderType === "Agent" && senderId === agentId) that
evaluateDeliveryEvent and evaluateDrainCandidate use internally,
exposed for a host's own call sites that classify a sender without going
through either function.
Turn outcome
TurnLedger judges one agent turn — design decisions live as rustdoc in
crates/core/src/turn.rs. Create one per turn, record the TurnEffect of
each successful tool call (a failed call records nothing), and ask for the
verdict() when the model stops: "complete" once the turn replied,
declined, or did real work, else "missing_reply" (fail the turn with
missingReplyMessage()). settle() marks a turn the SDK answered itself and
noteReported() one whose failure was already reported; both complete it.
replySettled() is true once a recorded call posted to the room or
declined to, so no fallback text is due. record throws a plain Error for
anything but a TurnEffect wire name.
bandToolEffects() maps every canonical Band tool name to its TurnEffect;
normalize an alias to its canonical name before the lookup. noReplyTool()
returns band_no_reply's definition as a ToolSpec (name, description,
parameters). turnEffects() and turnVerdicts() list the wire names.
import { TurnLedger, bandToolEffects } from "@band-ai/band-sdk-core";
const ledger = new TurnLedger();
ledger.record(bandToolEffects().band_send_event);
if (ledger.verdict() !== "missing_reply") {
throw new Error("an observe-only turn is a missing reply");
}
ledger.record("reply");
if (ledger.verdict() !== "complete" || !ledger.replySettled()) {
throw new Error("a reply must complete the turn");
}The public declaration is
index.d.ts.
Build / test
just check compiles and lints this crate for the host.
just test-wasm # suite in Node
just pack-npm # Node tarball + require/import consumers
just build-wasm # Node package outputRoom-activity observations
normalizeRoomActivitySnapshot(raw, selfAgentId, traceContext?) accepts the
decoded data object containing working_agents and returns typed native
RoomActivityPeer objects. It validates all rows, keeps the last duplicate
agent/execution key, excludes self and sorts by agent/execution with null
first. Output omits execution IDs. Missing detail and unknown string kinds
become reasoning; malformed fields throw Error with issues and
traceContext. The canonical field rules live on the Rust
normalize_room_activity_snapshot.
import { normalizeRoomActivitySnapshot } from "@band-ai/band-sdk-core";
const peers = normalizeRoomActivitySnapshot({working_agents: [
{agent_id: "peer", working: true, activity: {kind: "tool_call", name: "search"}},
]}, "self");
if (peers[0].activity.kind !== "tool_call" || peers[0].activity.name !== "search") {
throw new Error("expected peer tool declaration");
}Input must be JSON-native: plain objects from any realm (including null-prototype
objects) with data properties, dense arrays, finite JavaScript Number values
and Unicode scalar strings. Undefined values, sparse arrays,
accessors, symbols, nonfinite numbers and lone UTF-16 surrogates throw plain
Error before lossy conversion. Maximum depth is 64 (root zero), with 65,536
values and 1 MiB of UTF-8 string content including keys and string arguments.
Extra fields are checked too. Cycles and excessive input reject safely.
Each call is independent; the SDK owns I/O and availability. This observation
does not provide a lock or proof that no peer is working.
License
Proprietary, binary-only Band SDK License Agreement — see the bundled LICENSE. The Rust implementation source is private.
Fenced delivery settlement
guardedClaim shares eligibility across discovery paths. Its optional facts
object accepts boolean self_echo, durably_processed, durably_failed, and
allow_failed_retry fields. Only allow_failed_retry defaults to true. Unknown
fields and non-boolean values throw before ownership changes.
Pending originals remain excluded when parked. Record the terminal disposition before transport and confirm only after acceptance and a current-execution check. Retry confirmation restores charged attempts with the stricter retry ceiling; it does not falsely complete the original. Tokens are opaque local strings.
import { ClaimRegistry, RetryTracker } from "@band-ai/band-sdk-core";
const claims = new ClaimRegistry();
const previous = new RetryTracker(3);
if (claims.guardedClaim("room", "message", previous) !== "runnable") throw new Error("not admitted");
previous.recordAttempt("message");
const pending = claims.recordFailedSettlement("room", "message", "failure", previous, true);
claims.release("room", "message");
const current = new RetryTracker(3);
if (!claims.confirmSettlement("room", "message", pending.token, current)) throw new Error("not confirmed");
if (claims.isCompleted("room", "message")) throw new Error("retry falsely completed");
if (current.recordAttempt("message")[0] !== 2) throw new Error("charge lost");