@constal/sdk
The public TypeScript SDK for building on Constal.
npm install @constal/sdk
The package has one supported import path:
import { agent, gateway, credentialProvider } from "@constal/sdk";
Deep imports are not part of the API.
Public surface
The root export contains the contracts needed to author:
- Agents, subtasks, Tools, durable state, waits, stages, and ledger views
- Channels and Auth Providers
- executable Policies and governance constraints
- Connection packages through
gateway(...), including model, MCP, and Training Provider connections - Credential Provider packages through
credentialProvider(...) - Evals, Scorers, training recipes, memory operations, and sandbox handles
- CRNs, selectors, Resource requirements, schemas, hashes, and recovery-aware errors
Connection code receives GatewayContext/IntegrationContext: declared Credentials, bounded egress, package state, immutable artifact storage, and audit emission. Credential Provider code receives the same bounded capabilities through CredentialProviderContext.
gateway(...) and credentialProvider(...) return validated packages suitable for managed deployment. Users implement the package contract; Constal supplies its execution environment.
Example Connection
import { gateway } from "@constal/sdk";
export default gateway({
id: "ticket-connection",
version: "1.0.0",
kind: "service",
displayName: "Ticket connection",
description: "Read tickets from the configured account.",
credentialSlots: { account: { title: "Account Credential" } },
capabilities: [{
id: "example.ticket-read",
version: "1",
hash: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
}],
billing: { upstreamPayer: "tenant" },
configSchema: { type: "object", additionalProperties: false },
egress: { rules: [{
operations: ["ticket.get"],
origin: "https://api.example.com",
pathPrefix: "/tickets/",
methods: ["GET"],
}] },
catalog: () => [{
op: "ticket.get",
description: "Read one ticket.",
schema: { type: "object" },
resultSchema: { type: "object" },
effect: "read-only",
recovery: { kind: "repeat" },
async: false,
usage: { meter: "none", priceTable: null },
}],
async invoke(request, context) {
const credential = await context.secret("account");
return context.egress({
url: `https://api.example.com/tickets/${encodeURIComponent(String((request.args as { id: string }).id))}`,
method: "GET",
headers: { authorization: `Bearer ${credential.value}` },
maximumResponseBytes: 1_048_576,
maximumRedirects: 0,
});
},
});
Accepted Resource contracts
const bindings = Object.keys(ctx.resources);
const model = await ctx.describeResource("model");
const operations = (await ctx.describeResource("api"))?.operations;
The accessor records a local-step observation of one accepted Resource binding. It requires no Resource or provider lookup. Replaying that position returns the same observation; a later call can observe a safe-point binding update.
Results include public operation contracts, credential slot names, and advertised metadata for pinned Models. They omit credential references/material, private configuration, and control-plane operations. Returned objects are independent copies. A configured operation is not a promise of authorization: Policy still applies when it is invoked.
For model-selected operations, add the optional resourceTools to the Agent's
ordinary Tool catalog:
import { resourceTools } from "@constal/sdk";
export const tools = { ...resourceTools };
resource_list returns accepted binding names and CRNs without loading every
catalog. resource_describe takes { binding } and returns that Resource's
public operations. resource_invoke accepts the described operation and its
arguments:
await resourceTools.resource_invoke.run({
binding: "tickets",
operation: "tool.find",
arguments: { query: "Open support requests" },
}, ctx);
These Tools use the normal invocation, authorization, and asynchronous Handle contracts. Results and errors are preserved. They do not install packages, create Resources, change bindings, acquire credentials themselves, or retry failed operations. Use the Constal API Resource for installation and rebind; an accepted rebind receipt is not proof that the current frame has the new pin.
Skills
A skills Resource is one library of Agent Skills, installed by deploying an
archive that holds constal.skills.json. skills(binding, sandbox) returns
three Tools over the library the Agent binds as binding (default skills),
named from the binding with . and - as _:
import { agent, skills } from "@constal/sdk";
const library = skills("skills", "sandbox");
export default agent({
id: "maintainer", version: "1.0.0", model: "model",
tools: library,
async onMessage(request, ctx) {
return ctx.turn({ system: "You maintain the project's software.", context: { request }, tools: Object.keys(library) });
},
});
skills_find and skills_read are the library's find and read operations;
their schemas and descriptions come from its catalog at deployment.
skills_run bundles the skill, copies its folder once per bundle into the
workspace of the sandbox pool bound as sandbox (default sandbox), at
/workspace/skills/<ref>, and runs one command there; it answers the folder's
path and the command's result. It needs the library's bundle and the pool's
exec and putFile, under the pool's own Policy. Call skills() once per
bound library.
Ledger addresses and delivery
A ledger address names a Session's main ledger,
agent:<agent>/session:<session>, or a spawned loop's,
agent:<agent>/session:<session>/spawn:<handle>, within the Run's tenant,
namespace, and Customer. ctx.run.ledger is the current Run's own address (null
for an operator branch, an eval fork, a compensation, or a loop one of them
spawned, and for a Run of an existing Session whose id has a / or a control
character or is longer than 256 characters, which ingress no longer accepts for
a new one), and the Handle
ctx.spawn returns names its loop in ledger, the same across the spawn's
retries. A Run omits of and to for its own ledger; session:<id> is
refused.
const notes = await ctx.ledger.view("notes", { of: "agent:memory/session:team" });
await ctx.commit({ text: "Fix the failing build", data: { issue: 7 } },
{ to: "agent:task/session:fix-7", deliver: "live", delegate: true });
const investigation = ctx.spawn(investigate, { issue: 7 });
await ctx.commit("Also check the flaky test", { to: investigation.ledger, deliver: "live" });
const woken = ctx.await("next", { afterRun: "delivery", timeout: 3_600_000 });
ctx.ledger.view reads another ledger at its head. ctx.commit with to is
the one way agent code delivers to another ledger: deliver: "queue", the
default, queues the item; "live" steers the addressed Run or starts one. A
live item is a non-empty string or { text, data? }, and a delivery to a spawn
is live. delegate: true makes this Session the owner of the receiving Session,
which takes its owner only before its first Run. A delegation to a Session whose
live Run's cancel awaits its Driver waits behind that Run and starts the next
one; any other live delivery steers that Run. The platform then delivers to
the owner, as a RunReport, each end of that Session's root Runs (complete
with its result, failed, stopped) but one a stop of the owner's ledger
caused, each hold of one at its turn or spend limit (held), and each refused
or dropped start of one whose event did not come from the owner's Session
(refused). A delivery refused when it reaches
its Session, or failing there for good, is reported to its sender as a
RunReport with status refused. ctx.await(label, { afterRun: "delivery" })
settles when a delivery steers the waiting Run and resolves to { seq, eventId }
of the steer that woke it; the delivery itself stays in the ledger. A Run that a
delivery or a report starts belongs to its sender's end-to-end run, as does a
Run started through the Constal API from a Run: performance reads a Channel's,
a schedule's or an API call's Run and every Run it leads to together.
Policy decides a view of another Session (ledger:read; the pages its cursor
then reads need no further check) and a delivery to another Session at its
commit (agent:invoke on the receiving Agent, session:steer when live,
run:cancel when delegating). A refused view or
delivery throws its refusal, which is journaled, so a replay throws the same
error at the same position: a Policy denial is PolicyDecisionFailed with its
decisionHash, and any other refusal an Error with the platform's message and
status.
Session schedules
Schedule a future invocation of the current Agent and Session:
await ctx.schedule("check-pr-42", {
after: "10m",
input: { kind: "check-pr", repository: "acme/api", pullRequest: 42 },
});
The current Run can finish. The Session retains the schedule and delivers the
unchanged input to a new Run when due. Use every: "15m" for a fixed cadence, or
every: "day", at: "09:00", timezone: "America/Los_Angeles" for a daily check.
Recurring schedules preserve missed occurrences by default; explicitly request
missed: "coalesce" when one check should cover an accumulated backlog.
ctx.listSchedules() pages the owning Session's schedules; calling schedule()
again with the same name replaces future work. ctx.cancelSchedule(name) stops
future work and identifies accepted or unresolved deliveries. Use ctx.await()
for sleeping within the current Run.
See Schedule Agent follow-ups for replay, delivery, authority, and cancellation semantics.
Model evaluation
ctx.evaluate judges one state against typed questions through an evaluation
Model such as typesafe-ai/jev. Every question is answered independently with
constrained values and probabilities, never generated prose, and the call is
journaled and priced like a model call without consuming a turn.
import { agent, noulDecision } from "@constal/sdk";
export default agent({
id: "triage", version: "1.0.0", model: "model",
async onMessage(ticket, ctx) {
const { answers } = await ctx.evaluate({
model: "judge",
state: { ticket },
questions: {
team: { type: "choice", instructions: "Which team owns `ticket`?",
criteria: { billing: "Charges, invoices, refunds", technical: { what: "Bugs and outages", not_for: "Charges" } } },
severity: { type: "score", instructions: "How severe is the problem in `ticket`?",
criteria: ["Cosmetic", "Degraded", "Customer is blocked"] },
urgent: { type: "noul", instructions: "Does `ticket` convey urgency?" },
},
});
if (answers.team.confidence < 0.5) return { route: "human" };
return { route: answers.team.choice, severity: answers.severity.score, urgent: noulDecision(answers.urgent, 0.7) };
},
});
Bind the evaluation Model like any other ("judge": "crn:...:model/jev"), or
pass gateway with an explicit model identifier. instructions, Choice option
descriptions, Score levels, and Noul criteria all accept JSON structure. Choice
answers keep the literal option keys of their criteria, Score answers carry a
legend, and Noul answers are a probability.
See Evaluate state with a Model for a complete package manifest, answer shapes, and verification steps.