# Introduction (/docs)
Shadway runs agent work that lasts longer than a single request. An agent can wait
for a webhook, wait for a person to decide, survive a crash, and pick up where it
left off. The whole time, you get a clear record of what it was allowed to do and
what it actually did.
Three ideas cover the whole system.
The model and instructions behind the work.
What an agent can do.
One job, and the rules it follows.
## Start here [#start-here]
Install an SDK, authenticate, and run your first mandate.
The ideas the rest of the docs build on.
# Quickstart (/docs/quickstart)
Install an SDK, create an API key, and delegate your first piece of work. By the end
you will have created a mandate, watched what the agent did, and waited for it to
finish.
Every code sample on this site is available in TypeScript and Go. Use the language
selector on any sample to switch all of them at once.
Shadway is invite-only right now. You need an invite to get an API key and the
SDKs, which aren't published publicly yet. If you don't have access, request an
invite and come back once you're in. We're aiming for a public release and
general availability by October 2026.
## Before you begin [#before-you-begin]
You need a Shadway API key, which comes with your invite. A test key
(`sk_test_...`) creates only test-mode resources, and a live key (`sk_live_...`)
creates live ones. The key decides the mode. There is no separate setting.
You also need a model for the agent to reason with. The samples read it from
`SHADWAY_MODEL`. Set it to a provider-qualified model that your deployment
supports.
## 1. Install an SDK [#1-install-an-sdk]
While Shadway is invite-only, install from the source in your invite. The commands
below are how you'll install once the packages are public.
```bash
npm install shadway
```
The TypeScript SDK requires Node.js 20 or later and works in any runtime with the
Web Crypto API.
```bash
go get github.com/shadwayhq/shadway-go
```
The Go SDK requires Go 1.24 or later. Listing and streaming use range-over-func
iterators.
## 2. Authenticate [#2-authenticate]
Both SDKs read `SHADWAY_API_KEY` from the environment:
```bash
export SHADWAY_API_KEY="sk_test_..."
export SHADWAY_MODEL="your-provider/your-model"
```
Keep keys out of source control. The SDKs also accept the key directly in the
client constructor if your secrets live elsewhere.
## 3. Create an agent [#3-create-an-agent]
An agent defines the model, instructions, and capabilities that mandates will use.
Capabilities are named per action: `email.send`, not `email`.
```ts title="create-agent.ts"
import { Shadway } from "shadway";
const shadway = new Shadway();
const model = process.env.SHADWAY_MODEL;
if (!model) throw new Error("Set SHADWAY_MODEL");
const agent = await shadway.agents.create({
name: "Assistant",
model,
instructions: "Be concise. Report missing information instead of guessing.",
capabilities: ["email.send", "email.get_delivery"],
});
console.log(agent.id); // Save this ID in your application.
```
```go title="create_agent.go"
package main
import (
"context"
"fmt"
"log"
"os"
shadway "github.com/shadwayhq/shadway-go"
)
func main() {
client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
model := os.Getenv("SHADWAY_MODEL")
if model == "" {
log.Fatal("Set SHADWAY_MODEL")
}
instructions := "Be concise. Report missing information instead of guessing."
agent, err := client.Agents.Create(context.Background(), shadway.AgentCreateParams{
Name: "Assistant",
Model: model,
Instructions: &instructions,
Capabilities: []string{"email.send", "email.get_delivery"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(agent.ID) // Save this ID in your application.
}
```
The agent is reusable. You create it once and start many mandates against it, each
with its own objective and permissions.
## 4. Delegate work [#4-delegate-work]
A mandate is work you delegated: an objective, plus the authority that bounds it.
Replace the address below with a real contact before running the example.
```ts title="create-mandate.ts"
const mandate = await shadway.mandates.create({
agent: agent.id,
objective:
"Email sales@atlas.example and ask for a quote on 200 units of " +
"part #48-221, including shipping cost and delivery date. " +
"Report the quoted price and delivery date.",
authority: {
capabilities: ["email.send", "email.get_delivery"],
maximumActions: 5,
},
});
console.log(mandate.id, mandate.status);
```
```go title="create_mandate.go"
maxActions := int64(5)
mandate, err := client.Mandates.Create(ctx, shadway.MandateCreateParams{
Agent: agent.ID,
Objective: "Email sales@atlas.example and ask for a quote on 200 units of " +
"part #48-221, including shipping cost and delivery date. " +
"Report the quoted price and delivery date.",
Authority: &shadway.Authority{
Capabilities: []string{"email.send", "email.get_delivery"},
MaximumActions: &maxActions,
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(mandate.ID, mandate.Status)
```
Shadway enforces the authority on every action: this mandate can send email and
check delivery, take at most five consequential actions, and nothing else. The
objective and any guidance steer the agent but never enforce anything.
## 5. Wait for the result [#5-wait-for-the-result]
The mandate runs on Shadway's servers. Waiting only observes it. Ending the wait,
or your process, does not stop the work.
```ts title="wait.ts"
import { ShadwayTimeoutError } from "shadway";
const work = shadway.mandate(mandate.id);
try {
const result = await work.wait({ timeout: "15m" });
console.log(result.status);
console.log(result.currentState.summary);
} catch (error) {
if (!(error instanceof ShadwayTimeoutError)) throw error;
console.log("Still working. Reconnect later with mandate ID:", work.id);
}
```
```go title="wait.go"
result, err := client.Mandate(mandate.ID).Wait(ctx, shadway.WaitOptions{
Timeout: 15 * time.Minute,
})
if err != nil {
if shadway.IsTimeout(err) {
fmt.Println("Still working. Reconnect later with mandate ID:", mandate.ID)
return
}
log.Fatal(err)
}
fmt.Println(result.Status)
fmt.Println(result.CurrentState.Summary)
```
`wait` returns when the mandate reaches `completed`, `failed`, `canceled`, or
`expired`. A timeout only ends the local wait. If the supplier replies while you
are away, the agent handles it. If the wait for a reply times out, the agent
reasons about what to do next.
## What you built [#what-you-built]
You created a reusable agent, delegated one bounded piece of work to it, and
observed the outcome. Shadway checked every action against the mandate's
authority and recorded each one with its result.
The mental model the rest of the docs build on.
Configure a model, instructions, and capabilities, and follow a full week of
work through waits and restarts.
# Built-in capabilities (/docs/capabilities)
A capability is one thing an agent can do. Shadway comes with built-in capabilities
for common work, and you can add your own with a set input and output.
Built-in capabilities run on Shadway's own systems. You grant them and set limits,
and Shadway handles the providers, the credentials, and the retries. Names describe
the action, not the vendor: `email.send` says what happens, not who does it.
## Available capabilities [#available-capabilities]
| Capability | What it does | Effect |
| --------------------- | ------------------------------------------- | ------ |
| `email.send` | Send an email from the agent's own address | write |
| `email.get_delivery` | Check the delivery status of a sent email | read |
| `mailbox.read_thread` | Read a conversation in a connected mailbox | read |
| `mailbox.send` | Send a new message from a connected mailbox | write |
| `mailbox.reply` | Reply within an existing conversation | write |
| `phone.call` | Place an outbound phone call | write |
| `phone.get_call` | Check the status and outcome of a call | read |
| `browser.run` | Run a browsing task in a managed browser | write |
| `browser.get_run` | Check a browser run's progress and result | read |
| `browser.cancel_run` | Cancel a running browser task | write |
`email` and `mailbox` differ in whose address the mail comes from. `email.send`
uses the agent's own address. The `mailbox` actions use a mailbox that a person
connected, like their Gmail or Outlook, and send as that person.
Listing capabilities can return more than this table shows. Some actions are listed
but can't run yet, and they show `executionMode: "unavailable"` (`mailbox.search`
and `mailbox.get_attachment` are like this today). Check `executionMode` instead of
assuming a listed name can run.
```ts title="list-capabilities.ts"
for await (const capability of shadway.capabilities.list()) {
if (capability.status === "active" && capability.executionMode === "available") {
console.log(capability.name, "-", capability.description);
}
}
```
```go title="list_capabilities.go"
for capability, err := range client.Capabilities.List(ctx, nil) {
if err != nil {
log.Fatal(err)
}
if capability.Status == shadway.CapabilityStatusActive &&
capability.ExecutionMode == shadway.CapabilityExecutionModeAvailable {
fmt.Println(capability.Name, "-", capability.Description)
}
}
```
## Configure a capability [#configure-a-capability]
`capabilityConfig` on the agent sets options for capabilities the agent already
has. It never grants a capability. An entry here does nothing on its own without
the grant.
```ts title="configure.ts"
const updated = await shadway.agents.update(agent.id, {
capabilities: ["email.send", "email.get_delivery", "phone.call", "phone.get_call", "phone"],
capabilityConfig: {
phone: { country: "US", areaCode: "302" },
},
});
```
```go title="configure.go"
country := "US"
areaCode := "302"
updated, err := client.Agents.Update(ctx, agent.ID, shadway.AgentUpdateParams{
Capabilities: []string{
"email.send", "email.get_delivery", "phone.call", "phone.get_call", "phone",
},
CapabilityConfig: &shadway.AgentCapabilityConfigMap{
"phone": {Country: &country, AreaCode: &areaCode},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(updated.ID) // Same agent, new configuration revision.
```
The `phone` entry in the agent's capability list is what tells Shadway to get a
phone number, and it holds the settings for it. The dotted action names are what a
mandate can actually grant.
* **Phone**: `country` (a two-letter country code) and `areaCode` say what kind of
number you'd prefer. `resource` uses a number you already have instead of getting
a new one.
* **Browser**: `model` on the `browser` key runs browser work on a different model,
which has to handle images and tools. Leave it out and browser work uses the
default.
Getting a resource ready takes time. After you create or update an agent, read it
back and check `capabilityStatus`. Each entry is `provisioning`, `requires_action`,
`ready`, `unavailable`, or `failed`, with a `detail` for anything that isn't ready.
Phone goes through real setup and shows its number once it's ready. A capability
that has nothing to set up is `ready` as soon as the agent has it. A connected
mailbox's health shows up on its source, not here, so check the workspace's sources
if mailbox actions start failing.
## Define your own capability [#define-your-own-capability]
Your own capabilities work the same way as built-in ones: a dotted name, input and
output schemas, and a declared risk, effect, verification, and idempotency. For a
step-by-step, see
[Define your own capability](/docs/concepts/capabilities#define-your-own-capability).
Registering only defines the capability. In the current preview, capabilities you
host yourself can't run. A registered capability stays
`executionMode: "unavailable"` until something is set up to run it, and mandates
can't grant it before then.
## Continue building [#continue-building]
What each capability defines: risk, effect, verification, and idempotency.
Give capabilities to an agent and check they're ready.
# Agents (/docs/concepts/agents)
An agent defines the model, instructions, and capabilities used by a mandate.
Each job runs as a [mandate](/docs/concepts/mandates), with its own goal, its own
limits, and its own record. The same agent can collect quotes, negotiate a
purchase, or place an order, with different limits for each job.
The examples above use a custom `procurement.create_po` capability. You register its
inputs, its output, and the function that runs it. Shadway checks the mandate’s
limits before it runs.
## How agents work and wait [#how-agents-work-and-wait]
An agent can send a request and wait for a reply, a scheduled time, or an approval.
Shadway saves its progress while it waits. The agent resumes when the wait ends.
Open the messages to read the correspondence. Use Next step to follow the work
from Monday to Friday, and approve the purchase order when requested.
Saved progress survives process restarts.
In the timeout step, SAP has created the order but its response never arrives.
Shadway calls the capability’s configured checker, which looks up the purchase
reference and confirms the existing order. It records both the timeout and the
confirmation, then continues without placing another order.
This only works if the integration can check the real result. After a network error
or an unclear outcome, Shadway runs the checker you set up for it. If that check
also fails, the outcome stays unknown. Shadway doesn't call it a failure, and it
doesn't assume another purchase is safe.
The logos are just example systems. Gmail needs a mail integration you connect, and
SAP needs a purchasing integration and checker you build. The code below uses plain
capability names instead of promising built-in Gmail or SAP connectors.
What makes a waiting mandate resume?
A matching reply, a timer, or an answer to a pending approval or question can
start the work back up. Replies and approvals are kept separate, so an approval
about something else can't stand in for a supplier's reply. A wait for a reply can
also time out, which wakes the agent to decide what to do next.
Shadway handles replies, timers, and recovery while the agent waits. The agent
makes no model calls the whole time it's waiting.
See [Waiting](/docs/concepts/waiting) and [Approvals](/docs/concepts/approvals).
## Agent lifecycle in code [#agent-lifecycle-in-code]
This example buys 200 units of part #48-221 for less than $8,000, requests quotes
from three suppliers, and checks delivery. The agent handles replies and follow-up
messages. Your application sets permissions, presents approvals, and reads results.
The snippets use the official SDKs. Run them on your server. Set
`SHADWAY_API_KEY`, a supported provider-qualified `SHADWAY_MODEL`, and
`DELIVERY_DEADLINE` to a future RFC 3339 timestamp.
### 1. Create the agent [#1-create-the-agent]
```ts title="create-agent.ts"
import { Shadway, ShadwayTimeoutError, isKnownEvent } from "shadway";
const shadway = new Shadway();
const model = process.env.SHADWAY_MODEL;
if (!model) throw new Error("Set SHADWAY_MODEL");
const capabilities = [
"email.send",
"email.get_delivery",
"mailbox.read_thread",
"mailbox.reply",
"procurement.create_po",
"shipping.get_status",
];
const agent = await shadway.agents.create({
name: "Operations",
model,
instructions: [
"Compare supplier prices and delivery dates.",
"Ask for written confirmation of agreed terms.",
"Follow up when a supplier misses a promised response.",
"Report missing information instead of guessing.",
].join("\n"),
capabilities,
metadata: { team: "procurement" },
});
console.log(agent.id); // Save this ID in your application.
```
```go title="create_agent.go"
package main
import (
"context"
"fmt"
"log"
"os"
shadway "github.com/shadwayhq/shadway-go"
)
var capabilities = []string{
"email.send",
"email.get_delivery",
"mailbox.read_thread",
"mailbox.reply",
"procurement.create_po",
"shipping.get_status",
}
func main() {
client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
model := os.Getenv("SHADWAY_MODEL")
if model == "" {
log.Fatal("Set SHADWAY_MODEL")
}
instructions := "Compare supplier prices and delivery dates.\n" +
"Ask for written confirmation of agreed terms.\n" +
"Follow up when a supplier misses a promised response.\n" +
"Report missing information instead of guessing."
agent, err := client.Agents.Create(ctx, shadway.AgentCreateParams{
Name: "Operations",
Model: model,
Instructions: &instructions,
Capabilities: capabilities,
Metadata: &shadway.Metadata{"team": "procurement"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(agent.ID) // Save this ID in your application.
}
```
The `email` and `mailbox` actions are built-in capabilities. Every capability
name is a full action name such as `email.send`. A bare family name like
`email` is not grantable to a mandate. `procurement.create_po` and
`shipping.get_status` are example custom capabilities that your setup must
already have registered and hooked up to something that runs them. Registering the
shape of a capability alone doesn't make it runnable.
The code that runs the purchase order has to report the amount correctly and handle
repeat requests safely. Its checker and the shipment integration have to produce
the confirmed facts used below, `amount_minor` and `delivered`. These names are an
example, not facts Shadway produces for every purchase.
### 2. Read the configuration and check readiness [#2-read-the-configuration-and-check-readiness]
```ts title="check-agent.ts"
const savedAgent = await shadway.agents.get(agent.id);
console.log(savedAgent.name, savedAgent.model, savedAgent.status);
const capabilityStatus = savedAgent.capabilityStatus ?? {};
for (const [name, readiness] of Object.entries(capabilityStatus)) {
if (readiness.status !== "ready") {
throw new Error(
name + ": " + readiness.status + ": " + (readiness.detail ?? ""),
);
}
}
const requiredCustom = new Set([
"procurement.create_po",
"shipping.get_status",
]);
for await (const capability of shadway.capabilities.list()) {
if (
requiredCustom.has(capability.name) &&
capability.status === "active" &&
capability.executionMode === "available"
) {
requiredCustom.delete(capability.name);
}
}
if (requiredCustom.size > 0) {
throw new Error("Connect executors for: " + [...requiredCustom].join(", "));
}
```
```go title="check_agent.go"
savedAgent, err := client.Agents.Get(ctx, agent.ID)
if err != nil {
log.Fatal(err)
}
fmt.Println(savedAgent.Name, savedAgent.Model, savedAgent.Status)
if savedAgent.CapabilityStatus != nil {
for name, readiness := range *savedAgent.CapabilityStatus {
if readiness.Status != shadway.AgentCapabilityReadinessReady {
detail := ""
if readiness.Detail != nil {
detail = *readiness.Detail
}
log.Fatalf("%s: %s: %s", name, readiness.Status, detail)
}
}
}
requiredCustom := map[string]bool{
"procurement.create_po": true,
"shipping.get_status": true,
}
for capability, err := range client.Capabilities.List(ctx, nil) {
if err != nil {
log.Fatal(err)
}
if requiredCustom[capability.Name] &&
capability.Status == shadway.CapabilityStatusActive &&
capability.ExecutionMode == shadway.CapabilityExecutionModeAvailable {
delete(requiredCustom, capability.Name)
}
}
if len(requiredCustom) > 0 {
log.Fatalf("Connect executors for: %v", requiredCustom)
}
```
Not every capability reports readiness. If a capability has no status, that
doesn't mean it's ready. Shadway also checks that a capability can run when you
create a mandate. If setup is still going, finish it and read the agent again
before you continue.
### 3. Start the purchasing mandate [#3-start-the-purchasing-mandate]
The supplier addresses below are placeholders. Replace them with real contacts
before running the example. Supply an actual delivery deadline.
```ts title="start-mandate.ts"
const deadline = process.env.DELIVERY_DEADLINE;
if (
!deadline ||
!Number.isFinite(Date.parse(deadline)) ||
Date.parse(deadline) <= Date.now()
) {
throw new Error("Set DELIVERY_DEADLINE to a future RFC 3339 timestamp");
}
const mandate = await shadway.mandates.create({
agent: agent.id,
objective:
"Buy 200 units of part #48-221 for less than $8,000 total, " +
"including shipping. Confirm delivery by " + deadline + ".",
guidance: [
"Request quotes from sales@atlas.example, sales@beacon.example, " +
"and sales@cedar.example.",
"Include quantity, shipping cost, and delivery date in each request.",
"Wait up to 24 hours for replies, then follow up with missing suppliers.",
"Compare the quotes and negotiate before requesting purchase approval.",
"After ordering, check shipment status and contact the supplier if delayed.",
],
deadline: new Date(deadline),
authority: {
capabilities,
spend: {
maximum: { amount: 799_999, currency: "usd" },
},
maximumActions: 40,
maximumReadActions: 100,
capabilityLimits: [
{ capability: "procurement.create_po", maximumActions: 1 },
],
approval: {
requiredFor: ["procurement.create_po"],
},
successCriteria: [
{ fact: "amount_minor", max: 799_999 },
{ fact: "delivered", equals: true },
],
},
metadata: { purchaseRequest: "PR-1042" },
});
const work = shadway.mandate(mandate.id);
console.log(work.id); // Save this ID to reconnect later.
```
```go title="start_mandate.go"
deadlineRaw := os.Getenv("DELIVERY_DEADLINE")
deadline, err := time.Parse(time.RFC3339, deadlineRaw)
if err != nil || !deadline.After(time.Now()) {
log.Fatal("Set DELIVERY_DEADLINE to a future RFC 3339 timestamp")
}
maxActions := int64(40)
maxReadActions := int64(100)
poLimit := int64(1)
maxAmount := float64(799_999)
due := shadway.NullableTimestamp(&deadline)
mandate, err := client.Mandates.Create(ctx, shadway.MandateCreateParams{
Agent: agent.ID,
Objective: "Buy 200 units of part #48-221 for less than $8,000 total, " +
"including shipping. Confirm delivery by " + deadlineRaw + ".",
Guidance: []string{
"Request quotes from sales@atlas.example, sales@beacon.example, " +
"and sales@cedar.example.",
"Include quantity, shipping cost, and delivery date in each request.",
"Wait up to 24 hours for replies, then follow up with missing suppliers.",
"Compare the quotes and negotiate before requesting purchase approval.",
"After ordering, check shipment status and contact the supplier if delayed.",
},
Deadline: &due,
Authority: &shadway.Authority{
Capabilities: capabilities,
Spend: &shadway.SpendAuthority{
Maximum: shadway.Money{Amount: 799_999, Currency: "usd"},
},
MaximumActions: &maxActions,
MaximumReadActions: &maxReadActions,
CapabilityLimits: []shadway.CapabilityLimit{
{Capability: "procurement.create_po", MaximumActions: &poLimit},
},
Approval: &shadway.ApprovalAuthority{
RequiredFor: []string{"procurement.create_po"},
},
SuccessCriteria: []shadway.SuccessCriterion{
{Fact: "amount_minor", Max: &maxAmount},
{Fact: "delivered", Equals: true},
},
},
Metadata: &shadway.Metadata{"purchaseRequest": "PR-1042"},
})
if err != nil {
log.Fatal(err)
}
work := client.Mandate(mandate.ID)
fmt.Println(work.ID()) // Save this ID to reconnect later.
```
USD amounts are cents: 799,999 means $7,999.99. The spending limit, action limits,
approval requirement, and success criteria are enforced. The follow-up timing and
supplier comparison instructions are guidance, not enforced rules. The server
decides when to wait and resume. Your application does not send each email itself.
Use a stable idempotency key if you might retry this same creation across app
restarts. The SDKs generate one per call, but two separate calls can still create
two mandates.
### 4. Monitor progress [#4-monitor-progress]
Run the event consumer in a separate worker or request from the approval flow.
Reconnect using the saved mandate ID. Creating a handle does not start new work.
```ts title="monitor.ts"
async function monitor(mandateId: string, signal: AbortSignal) {
const handle = shadway.mandate(mandateId);
const current = await handle.get();
console.log(current.status, current.currentState);
if (["completed", "failed", "canceled", "expired"].includes(current.status)) {
return;
}
for await (const event of handle.events.stream({ signal })) {
if (!isKnownEvent(event)) continue;
console.log(event.id, event.type, event.data);
if (
event.type === "mandate.status_changed" &&
["completed", "failed", "canceled", "expired"].includes(
event.data.mandate.status,
)
) {
break;
}
}
}
```
```go title="monitor.go"
func monitor(ctx context.Context, client *shadway.Client, mandateID string) error {
handle := client.Mandate(mandateID)
current, err := handle.Get(ctx)
if err != nil {
return err
}
fmt.Println(current.Status, current.CurrentState)
switch current.Status {
case shadway.MandateStatusCompleted, shadway.MandateStatusFailed,
shadway.MandateStatusCanceled, shadway.MandateStatusExpired:
return nil
}
for event, err := range handle.Stream(ctx, shadway.EventStreamOptions{}) {
if err != nil {
return err
}
if !event.IsKnown() {
continue
}
fmt.Println(event.ID, event.Type, event.Data)
if data, ok := event.Data.(*shadway.MandateStatusChangedData); ok {
switch data.Mandate.Status {
case shadway.MandateStatusCompleted, shadway.MandateStatusFailed,
shadway.MandateStatusCanceled, shadway.MandateStatusExpired:
return nil
}
}
}
return nil
}
```
Save the last processed event ID and pass it as the stream's last-event-ID option
when reconnecting after your application restarts. Ending the stream stops
observation. It does not cancel the mandate. For a terminal-state wait that also
handles work that already finished, use the wait call below.
### 5. Present an approval and record the decision [#5-present-an-approval-and-record-the-decision]
```ts title="request-approval.ts"
try {
const approval = await work.waitForApproval({ timeout: "30m" });
if (approval) {
// Present these fields in your application's approval screen.
console.log({
id: approval.id,
question: approval.question,
action: approval.action,
expiresAt: approval.expiresAt,
});
} else {
console.log("The mandate ended without a pending approval.");
}
} catch (error) {
if (!(error instanceof ShadwayTimeoutError)) throw error;
console.log("No approval yet. The mandate continues on the server.");
}
```
```go title="request_approval.go"
approval, err := work.WaitForApproval(ctx, shadway.WaitOptions{
Timeout: 30 * time.Minute,
})
if err != nil {
if shadway.IsTimeout(err) {
fmt.Println("No approval yet. The mandate continues on the server.")
return
}
log.Fatal(err)
}
if approval == nil {
fmt.Println("The mandate ended without a pending approval.")
return
}
// Present these fields in your application's approval screen.
fmt.Println(approval.ID, approval.Question)
fmt.Println(approval.Action.Capability, approval.Action.Consequence)
fmt.Println(approval.ExpiresAt)
```
Call the following function only after an authorized person reviews the action
and submits a decision. Authenticate that person and check their access to this
mandate in your application before calling it.
```ts title="decide-approval.ts"
async function decidePurchase(
approvalId: string,
mandateId: string,
decision: "approve" | "reject",
reason: string,
) {
const approval = await shadway.approvals.get(approvalId);
if (approval.mandate !== mandateId || approval.status !== "pending") {
throw new Error("This approval is not pending for this mandate");
}
if (decision === "approve") {
return shadway.approvals.approve(approval.id, { reason });
}
return shadway.approvals.reject(approval.id, { reason });
}
```
```go title="decide_approval.go"
func decidePurchase(ctx context.Context, client *shadway.Client, approvalID, mandateID, decision, reason string) (*shadway.Approval, error) {
approval, err := client.Approvals.Get(ctx, approvalID)
if err != nil {
return nil, err
}
if approval.Mandate != mandateID || approval.Status != shadway.ApprovalStatusPending {
return nil, errors.New("this approval is not pending for this mandate")
}
if decision == "approve" {
return client.Approvals.Approve(ctx, approval.ID, &shadway.ApprovalApproveParams{Reason: &reason})
}
return client.Approvals.Reject(ctx, approval.ID, &shadway.ApprovalRejectParams{Reason: &reason})
}
```
Approval permits the reviewed action only. It does not remove the spending limit
or permit another order. A rejection refuses one action. Cancel the mandate
explicitly if the entire task should stop. An expired or already-resolved
approval can be rejected by the server even if an earlier read showed it pending.
### 6. Inspect the result and its evidence [#6-inspect-the-result-and-its-evidence]
```ts title="inspect-result.ts"
try {
const result = await work.wait({ timeout: "30m" });
console.log(result.status, result.currentState.summary);
if (result.status === "completed") {
for await (const fact of work.facts.list()) {
console.log("Fact", fact);
}
for await (const evidence of work.evidence.list()) {
console.log("Evidence", evidence);
}
} else {
console.log("The task ended without completing the objective.");
for await (const issue of work.issues.list()) {
console.log("Issue", issue);
}
}
} catch (error) {
if (!(error instanceof ShadwayTimeoutError)) throw error;
console.log("Still waiting. Reconnect later with mandate ID:", work.id);
}
```
```go title="inspect_result.go"
result, err := work.Wait(ctx, shadway.WaitOptions{Timeout: 30 * time.Minute})
if err != nil {
if shadway.IsTimeout(err) {
fmt.Println("Still waiting. Reconnect later with mandate ID:", work.ID())
return
}
log.Fatal(err)
}
fmt.Println(result.Status, result.CurrentState.Summary)
if result.Status == shadway.MandateStatusCompleted {
for fact, err := range work.Facts(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println("Fact", fact)
}
for evidence, err := range work.Evidence(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println("Evidence", evidence)
}
} else {
fmt.Println("The task ended without completing the objective.")
for issue, err := range work.Issues(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println("Issue", issue)
}
}
```
The wait returns on `completed`, `failed`, `canceled`, or `expired`.
A timeout only ends the local wait. It neither cancels the mandate nor resolves
an approval. A successful purchase API response is not proof of delivery.
Completion depends on the confirmed facts and configured success criteria.
### 7. Pause, resume, or cancel work [#7-pause-resume-or-cancel-work]
These are separate operator actions. Call the relevant function in response to a
decision in your application. Do not run all three in sequence.
```ts title="control-work.ts"
async function pausePurchase(mandateId: string) {
return shadway.mandate(mandateId).pause({
reason: "Procurement is reviewing the specification.",
});
}
async function resumePurchase(mandateId: string) {
return shadway.mandate(mandateId).resume({
reason: "The specification is confirmed.",
});
}
async function cancelPurchase(mandateId: string) {
return shadway.mandate(mandateId).cancel({
reason: "The part is no longer needed.",
});
}
```
```go title="control_work.go"
func pausePurchase(ctx context.Context, client *shadway.Client, mandateID string) (*shadway.Mandate, error) {
reason := "Procurement is reviewing the specification."
return client.Mandate(mandateID).Pause(ctx, &shadway.MandateCommandParams{Reason: &reason})
}
func resumePurchase(ctx context.Context, client *shadway.Client, mandateID string) (*shadway.Mandate, error) {
reason := "The specification is confirmed."
return client.Mandate(mandateID).Resume(ctx, &shadway.MandateCommandParams{Reason: &reason})
}
func cancelPurchase(ctx context.Context, client *shadway.Client, mandateID string) (*shadway.Mandate, error) {
reason := "The part is no longer needed."
return client.Mandate(mandateID).Cancel(ctx, &shadway.MandateCommandParams{Reason: &reason})
}
```
Cancellation stops further work. It does not undo an email already sent or an
order already placed.
### 8. Update and manage the reusable agent [#8-update-and-manage-the-reusable-agent]
```ts title="manage-agent.ts"
const revisedAgent = await shadway.agents.update(agent.id, {
instructions: [
"Compare supplier prices and delivery dates.",
"Request a backup delivery option for urgent orders.",
"Ask for written confirmation of agreed terms.",
"Report missing information instead of guessing.",
].join("\n"),
});
for await (const item of shadway.agents.list({ limit: 20 })) {
console.log(item.id, item.name, item.status);
}
console.log(revisedAgent.id); // Same agent ID, updated configuration.
```
```go title="manage_agent.go"
revisedInstructions := "Compare supplier prices and delivery dates.\n" +
"Request a backup delivery option for urgent orders.\n" +
"Ask for written confirmation of agreed terms.\n" +
"Report missing information instead of guessing."
revisedAgent, err := client.Agents.Update(ctx, agent.ID, shadway.AgentUpdateParams{
Instructions: &revisedInstructions,
})
if err != nil {
log.Fatal(err)
}
for item, err := range client.Agents.List(ctx, &shadway.ListParams{Limit: 20}) {
if err != nil {
log.Fatal(err)
}
fmt.Println(item.ID, item.Name, item.Status)
}
fmt.Println(revisedAgent.ID) // Same agent ID, updated configuration.
```
Existing mandates retain their saved agent configuration. New mandates use the
updated version. To reuse the agent, create another mandate with the revised
agent's ID and a new objective and authority.
```ts title="set-agent-status.ts"
async function disableAgent(agentId: string) {
return shadway.agents.update(agentId, { status: "disabled" });
}
async function enableAgent(agentId: string) {
return shadway.agents.update(agentId, { status: "active" });
}
```
```go title="set_agent_status.go"
func disableAgent(ctx context.Context, client *shadway.Client, agentID string) (*shadway.Agent, error) {
status := shadway.AgentStatusDisabled
return client.Agents.Update(ctx, agentID, shadway.AgentUpdateParams{Status: &status})
}
func enableAgent(ctx context.Context, client *shadway.Client, agentID string) (*shadway.Agent, error) {
status := shadway.AgentStatusActive
return client.Agents.Update(ctx, agentID, shadway.AgentUpdateParams{Status: &status})
}
```
The agent API supports create, get, list, and update. It has no delete method.
Changing agent status is separate from stopping work. Use the mandate cancellation
command for each task you need to stop.
## Set instructions and permissions [#set-instructions-and-permissions]
Agent instructions describe how the agent should behave. The mandate’s objective
describes what it should accomplish. Its authority defines what it may do.
An agent may have purchasing capabilities while a particular mandate only allows
it to request quotes. Set that mandate’s allowed capabilities in
`authority.capabilities`. If omitted or empty, it uses the agent’s full set.
See [Capabilities](/docs/concepts/capabilities) for action definitions and
[Verification](/docs/concepts/verification) for checking results.
## Configure capabilities [#configure-capabilities]
Capability names are action names: built-ins such as `email.send`,
`mailbox.read_thread`, `browser.run`, and `phone.call`, or registered custom
names. Provider brands are not capability aliases. An agent’s list may also
carry a family alias such as `phone`, which provisions and configures a managed
resource, but a mandate’s authority grants exact action names only.
Optional `capabilityConfig` supplies settings for capabilities the agent already
has. It does not add capabilities. For example, request a phone number with a
preferred area code:
```ts title="configure.ts"
const updated = await shadway.agents.update(agent.id, {
capabilities: [
"email.send",
"email.get_delivery",
"mailbox.read_thread",
"mailbox.reply",
"phone.call",
"phone.get_call",
"phone",
],
capabilityConfig: {
phone: { country: "US", areaCode: "302" },
},
});
```
```go title="configure.go"
country := "US"
areaCode := "302"
updated, err := client.Agents.Update(ctx, agent.ID, shadway.AgentUpdateParams{
Capabilities: []string{
"email.send",
"email.get_delivery",
"mailbox.read_thread",
"mailbox.reply",
"phone.call",
"phone.get_call",
"phone",
},
CapabilityConfig: &shadway.AgentCapabilityConfigMap{
"phone": {Country: &country, AreaCode: &areaCode},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(updated.ID) // Same agent, new configuration revision.
```
Managed capabilities can take time to set up. After creating or updating an agent,
read it again and check `capabilityStatus` to see which capabilities are ready.
Reported states are `provisioning`, `requires_action`, `ready`, `unavailable`, and
`failed`, and `detail` explains why a capability is not ready.
Use a separate model for browser work
Set `capabilityConfig.browser.model` on an agent that includes browser
capabilities. The model must support image input and tool use, and be supported
by the deployment. If omitted, browser work uses the deployment default. Each new mandate saves
this setting with its agent configuration.
## Update without changing existing work [#update-without-changing-existing-work]
Changing an agent’s name, model, instructions, capabilities, or capability settings
creates a new saved version. New mandates use that version. Existing mandates
keep the configuration they started with.
```ts title="update.ts"
await shadway.agents.update(agent.id, {
instructions:
"Be concise with suppliers. Record every concession. " +
"Require written confirmation of agreed terms.",
});
```
```go title="update.go"
concise := "Be concise with suppliers. Record every concession. " +
"Require written confirmation of agreed terms."
if _, err := client.Agents.Update(ctx, agent.ID, shadway.AgentUpdateParams{
Instructions: &concise,
}); err != nil {
log.Fatal(err)
}
```
Updates are partial: omitted fields stay unchanged. Changes only to `status` or
`metadata` do not create a configuration revision. To change an existing
mandate’s authority, use the mandate’s own controls.
## Continue building [#continue-building]
Set a task’s objective and permissions.
Define the actions an agent can use.
# Approvals (/docs/concepts/approvals)
Sometimes a mandate reaches an action that needs a person to sign off before it can
go ahead. That's an approval. The mandate waits until someone approves or rejects
it.
Shadway captures the exact action it's about to take, a person reviews it, and the
decision goes into the mandate's record either way.
## Where approvals come from [#where-approvals-come-from]
You set up some approvals yourself. Others happen automatically:
* **`authority.approval.requiredFor`**: actions that always need sign-off in this
mandate, no matter the amount.
* **`authority.spend.approvalThreshold`**: any single action above this amount
needs sign-off, even if it's under the total spending limit.
* **Automatic**: Shadway pauses an action on its own when it's risky and hard to
undo, or when a costly action is based on outside data it hasn't confirmed.
Treat every pending approval as real, even ones you didn't set up. An approval you
didn't ask for means Shadway paused the action on purpose, not that something went
wrong.
## What a person reviews [#what-a-person-reviews]
The approval includes the question, the exact action it's about to take, and when
the request expires. The action's arguments are shown with secrets hidden. Secret
values never show up in an approval, in events, or in logs.
## Present an approval and record the decision [#present-an-approval-and-record-the-decision]
Wait for an approval on a mandate you're running, or list all the pending approvals
in your workspace to build a review inbox.
```ts title="present-approval.ts"
import { ShadwayTimeoutError } from "shadway";
const work = shadway.mandate(mandateId);
try {
const approval = await work.waitForApproval({ timeout: "30m" });
if (approval) {
// Render these fields in your application's approval screen.
console.log({
id: approval.id,
question: approval.question,
action: approval.action,
expiresAt: approval.expiresAt,
});
} else {
console.log("The mandate ended without asking for an approval.");
}
} catch (error) {
if (!(error instanceof ShadwayTimeoutError)) throw error;
console.log("No approval yet. The mandate continues on the server.");
}
```
```go title="present_approval.go"
work := client.Mandate(mandateID)
approval, err := work.WaitForApproval(ctx, shadway.WaitOptions{
Timeout: 30 * time.Minute,
})
if err != nil {
if shadway.IsTimeout(err) {
fmt.Println("No approval yet. The mandate continues on the server.")
return
}
log.Fatal(err)
}
if approval == nil {
fmt.Println("The mandate ended without asking for an approval.")
return
}
// Render these fields in your application's approval screen.
fmt.Println(approval.ID, approval.Question)
fmt.Println(approval.Action.Capability, approval.Action.Consequence)
```
Only call the deciding function after a person with the right access has reviewed
the action and made a decision. Sign that person in and check that they're allowed
to act on this mandate before you call it.
```ts title="decide-approval.ts"
async function decide(
approvalId: string,
mandateId: string,
decision: "approve" | "reject",
reason: string,
) {
const approval = await shadway.approvals.get(approvalId);
if (approval.mandate !== mandateId || approval.status !== "pending") {
throw new Error("This approval is not pending for this mandate");
}
if (decision === "approve") {
return shadway.approvals.approve(approval.id, { reason });
}
return shadway.approvals.reject(approval.id, { reason });
}
```
```go title="decide_approval.go"
func decide(ctx context.Context, client *shadway.Client, approvalID, mandateID, decision, reason string) (*shadway.Approval, error) {
approval, err := client.Approvals.Get(ctx, approvalID)
if err != nil {
return nil, err
}
if approval.Mandate != mandateID || approval.Status != shadway.ApprovalStatusPending {
return nil, errors.New("this approval is not pending for this mandate")
}
if decision == "approve" {
return client.Approvals.Approve(ctx, approval.ID, &shadway.ApprovalApproveParams{Reason: &reason})
}
return client.Approvals.Reject(ctx, approval.ID, &shadway.ApprovalRejectParams{Reason: &reason})
}
```
Approving allows only the exact action shown, and the mandate's existing limits
still apply. It doesn't raise the spending limit, and it doesn't allow a second
order. Right before running the action, Shadway rechecks anything that depends on
time, like whether the mandate has expired.
## Decisions are final, and approvals expire [#decisions-are-final-and-approvals-expire]
The first decision wins. Sending the same decision again returns the original
result, so a retry after a dropped connection is safe. Sending the opposite
decision is rejected, so it can't overwrite the first one.
Every approval expires, at most seven days after it's requested. A decision that
lands after it expires marks the approval `expired` instead of running the action.
A late approval never runs. Rejecting refuses one action, not the whole job. Cancel
the mandate if you want everything to stop. Canceling a mandate also clears its
pending approvals.
## Pass sensitive values with a decision [#pass-sensitive-values-with-a-decision]
Some approved actions need a value only a person has, like a one-time code or a
password for an old system. Pass it in `inputs` when you approve:
```ts title="approve-with-inputs.ts"
await shadway.approvals.approve(approvalId, {
reason: "Verified with the account holder.",
inputs: { otp: "483921" },
});
```
```go title="approve_with_inputs.go"
reason := "Verified with the account holder."
_, err := client.Approvals.Approve(ctx, approvalID, &shadway.ApprovalApproveParams{
Reason: &reason,
Inputs: map[string]string{"otp": "483921"},
})
if err != nil {
log.Fatal(err)
}
```
These inputs are only for running this one action. Shadway keeps them briefly as
encrypted data, never shows them to the model, the event stream, logs, or API
responses, and deletes them once the action is done. You can't pass inputs when you
reject.
## Input requests [#input-requests]
When the agent needs something from a person, like a code, a link, or an account
detail, it comes through as an approval too. The `action.capability` is
`input.provide` and the question is in `action.consequence`. One approval screen
handles both.
## Continue building [#continue-building]
How Shadway checks what actually happened.
The full pattern for an approval screen, end to end.
# Capabilities (/docs/concepts/capabilities)
A capability is one thing an agent can do, like send an email or place a call. Each
one takes specific inputs and returns a specific output. You give an agent
capabilities to decide what it's allowed to do.
Capabilities are named per action, and the name always has a dot in it:
`email.send`, `mailbox.read_thread`, `browser.run`. The name describes the action,
not the vendor. `email.send` says what happens, not which email provider does it.
## What a capability defines [#what-a-capability-defines]
Each capability spells out what the action does, how risky it is, and how Shadway
should treat the result. This is what lets Shadway apply limits and check outcomes
without knowing anything else about your integration.
* **`risk`**: what kind of harm a mistake could cause: `none`, `communications`,
`financial`, `legal`, `physical`, or `custom`.
* **`effect`**: whether the action reads, writes, or does something you can't undo.
Reads have their own budget, and actions you can't undo are handled more
carefully.
* **`verify`**: how Shadway confirms the result: `none`, `read_after_write`,
`authoritative_source`, or `human`. See [Verification](/docs/concepts/verification).
* **`idempotency`**: whether the action is safe to repeat: `native` (always safe),
`keyed` (safe when the vendor honors an idempotency key), `reconcilable` (not
safe to repeat, but Shadway can check afterward what happened), or `none` (not
safe, and not checkable).
* **`executionMode`**: set by Shadway, not you. `available` means the capability
can actually run. `unavailable` means it's defined but has nothing behind it yet.
A mandate that needs an unavailable capability is rejected right away.
## How capabilities narrow [#how-capabilities-narrow]
A capability passes through three levels, and each level can only cut down the one
before it. Your workspace defines the full set, an agent gets some of them, and a
mandate gets some of what the agent has, for one job.
The mandate is where the limits actually bite: every name in
`authority.capabilities` has to be a full action name that the agent has and the
workspace knows. An agent's own `capabilities` list can also include a family name
like `phone`, which is used to set up and configure the capability, but a mandate
can only grant exact action names.
## Define your own capability [#define-your-own-capability]
Registering your own capability gives your action the same setup as a built-in one.
You define the input and output types, and the SDK turns them into JSON schemas.
```ts title="register-capability.ts"
import { defineCapability } from "shadway";
import { z } from "zod";
const createPurchaseOrder = defineCapability({
name: "procurement.create_po",
description: "Create a purchase order in the ERP system.",
input: z.object({
supplier: z.string(),
part: z.string(),
quantity: z.number().int().positive(),
amount_minor: z.number().int().positive(),
}),
output: z.object({
purchase_reference: z.string(),
}),
risk: "financial",
effect: "irreversible",
execute: async (input) => {
// Call your ERP system here.
return { purchase_reference: "PO-2041" };
},
});
await shadway.capabilities.register(createPurchaseOrder);
```
```go title="register_capability.go"
type PurchaseOrderInput struct {
Supplier string `json:"supplier"`
Part string `json:"part"`
Quantity int `json:"quantity"`
AmountMinor int64 `json:"amount_minor"`
}
type PurchaseOrderOutput struct {
PurchaseReference string `json:"purchase_reference"`
}
risk := shadway.CapabilityRiskFinancial
effect := shadway.CapabilityEffectIrreversible
createPO, err := shadway.DefineCapability(shadway.DefineCapabilityOptions[PurchaseOrderInput, PurchaseOrderOutput]{
Name: "procurement.create_po",
Description: "Create a purchase order in the ERP system.",
Risk: &risk,
Effect: &effect,
Execute: func(ctx context.Context, input PurchaseOrderInput) (PurchaseOrderOutput, error) {
// Call your ERP system here.
return PurchaseOrderOutput{PurchaseReference: "PO-2041"}, nil
},
})
if err != nil {
log.Fatal(err)
}
if _, err := client.Capabilities.Register(ctx, createPO); err != nil {
log.Fatal(err)
}
```
Registering a capability only defines it. It stays
executionMode: "unavailable" until something is set up to run it,
and a mandate can't use it until then. In the current preview, capabilities you
host yourself can't run yet. Built-in capabilities run on Shadway's own systems.
A capability's input and output are part of what it is. After you register it, you
can only change the description, status, and metadata. To change the inputs or
outputs, register a new capability under a new name or version.
## Check whether a capability is ready [#check-whether-a-capability-is-ready]
Some built-in capabilities have to set something up first. A phone capability, for
example, has to get a phone number. After you create or update an agent, read it
back and check `capabilityStatus`. Each entry is `provisioning`, `requires_action`,
`ready`, `unavailable`, or `failed`, with a `detail` that explains anything that
isn't ready. A capability that doesn't need to set anything up is `ready` as soon
as the agent has it.
```ts title="check-readiness.ts"
const saved = await shadway.agents.get(agent.id);
for (const [name, readiness] of Object.entries(saved.capabilityStatus ?? {})) {
if (readiness.status !== "ready") {
console.log(name, readiness.status, readiness.detail ?? "");
}
}
```
```go title="check_readiness.go"
saved, err := client.Agents.Get(ctx, agent.ID)
if err != nil {
log.Fatal(err)
}
if saved.CapabilityStatus != nil {
for name, readiness := range *saved.CapabilityStatus {
if readiness.Status != "ready" {
detail := ""
if readiness.Detail != nil {
detail = *readiness.Detail
}
fmt.Println(name, readiness.Status, detail)
}
}
}
```
## Continue building [#continue-building]
The email, mailbox, phone, and browser actions, and how to configure each.
Give capabilities to one job, with limits Shadway enforces.
# Execution (/docs/concepts/execution)
Once you create a mandate, Shadway runs it. It plans the next step, takes an
action, waits when it has to, and recovers after a crash, all on its own, until the
job ends. Your app watches. It doesn't drive the work.
## How every action runs [#how-every-action-runs]
The model never runs an action itself. It suggests one, and Shadway does the rest:
* The model proposes an action, like `email.send`.
* Shadway checks it against the mandate's limits.
* If it's allowed, an integration runs it with its own credentials.
* Shadway records what was decided and what happened.
Every action goes through all four steps, every time.
Two things come out of this. The model never sees a credential: it asks for
`email.send`, and the integration holds the keys. And a blocked action is recorded
as blocked, never as done, so the record always shows the difference between what
the agent tried and what actually happened.
## How work survives a crash [#how-work-survives-a-crash]
Shadway saves every step as it goes. If the server restarts, redeploys, or crashes
in the middle of a job, nothing is lost: the mandate picks up at the exact step
where it stopped, with everything it knew.
This is also why [waiting](/docs/concepts/waiting) is free. A waiting mandate
isn't a running process. It's a saved spot that a reply, a timer, or a decision
starts back up.
Don't assume an action runs exactly once. Shadway gives each action an
idempotency key, so a safe retry lands on the same result. When the result is
unclear, say the request went out but timed out before the reply, Shadway asks
the integration what actually happened instead of blindly trying again.
Whether an action is safe to repeat is part of each capability's definition. Its
`idempotency` setting says the action is safe to repeat on its own, safe to repeat
when the vendor honors an idempotency key, not safe to repeat but checkable
afterward (`reconcilable`), or none of these. In the last case, Shadway won't
repeat it at all. If Shadway can't confirm what happened, it leaves the result
unknown. It never quietly turns that into a success or a reason to try again.
## Request idempotency [#request-idempotency]
Every API call that changes something carries an `Idempotency-Key` header. Both
SDKs add one automatically and reuse it when they retry, so a dropped connection
never creates two mandates. Set your own key when a retry might happen across app
restarts:
```ts title="idempotent-create.ts"
const mandate = await shadway.mandates.create(
{
agent: agent.id,
objective: "Renew the domain registration before it lapses.",
},
{ idempotencyKey: "renewal-2026-q3" },
);
```
```go title="idempotent_create.go"
mandate, err := client.Mandates.Create(ctx, shadway.MandateCreateParams{
Agent: agent.ID,
Objective: "Renew the domain registration before it lapses.",
}, shadway.WithIdempotencyKey("renewal-2026-q3"))
if err != nil {
log.Fatal(err)
}
fmt.Println(mandate.ID) // The same ID on every retry of this creation.
```
The same key with the same request returns the first result. The same key with a
different request is rejected, so a retry can't quietly do something new. Keys last
at least 24 hours.
## Watch execution through events [#watch-execution-through-events]
Shadway reports each action as an event: `capability.execution_started`,
`capability.execution_completed`, and `capability.execution_failed`, each with the
capability name, how long it took, and any error.
```ts title="watch-executions.ts"
import { isKnownEvent } from "shadway";
for await (const event of shadway.mandate(mandate.id).events.stream({ signal })) {
if (!isKnownEvent(event)) continue;
if (event.type === "capability.execution_completed") {
const execution = event.data.execution;
console.log(execution.capability, execution.durationMs ?? "?", "ms");
}
if (event.type === "capability.execution_failed") {
console.log("failed:", event.data.execution.error?.message);
}
}
```
```go title="watch_executions.go"
for event, err := range client.Mandate(mandate.ID).Stream(ctx, shadway.EventStreamOptions{}) {
if err != nil {
log.Fatal(err)
}
data, ok := event.Data.(*shadway.CapabilityExecutionEventData)
if !ok {
continue
}
switch event.Type {
case shadway.EventTypeCapabilityExecutionCompleted:
if ms := data.Execution.DurationMs; ms != nil {
fmt.Println(data.Execution.Capability, *ms, "ms")
}
case shadway.EventTypeCapabilityExecutionFailed:
if data.Execution.Error != nil {
fmt.Println("failed:", data.Execution.Error.Message)
}
}
}
```
For the full streaming setup, including resuming after a restart, skipping
duplicates, and filling gaps, see
[Stream a mandate's events](/docs/guides/stream-events).
## Changing limits on a running mandate [#changing-limits-on-a-running-mandate]
Shadway re-reads the mandate before every step, so changes take effect on a
running mandate without stopping it. An
[amendment](/docs/concepts/mandates#change-authority-with-an-amendment) applies at
the next step. A cancellation takes effect at the next safe point. If `expiresAt`
passes, the next action won't run, no matter how long the mandate has been going.
## Continue building [#continue-building]
How a mandate pauses for time, events, or people.
How Shadway checks what actually happened.
# Concepts (/docs/concepts)
Shadway is built on three ideas:
* **Agent**: the model and instructions behind the work.
* **Capability**: one thing an agent can do.
* **Mandate**: one job, with the rules it has to follow.
Everything else builds on these. An approval is a point in a mandate where a person
has to decide. Events are the running record of what a mandate did. Evidence is the
proof behind what Shadway believes happened. Commitments are promises the agent made
or received while doing the job.
The model and instructions behind the work.
What an agent can do.
One job, and the rules it follows.
How a mandate runs, survives crashes, and picks back up.
How a mandate waits for time, events, or people.
When a mandate pauses for a person to approve an action.
How Shadway checks what actually happened.
# Mandates (/docs/concepts/mandates)
A mandate is one job you hand to Shadway, along with the rules it has to follow.
You create a mandate, watch it, and wait for it to finish.
An [agent](/docs/concepts/agents) is reusable. A mandate is one job. The same agent
can collect quotes under one mandate and place orders under another, each with its
own goal, its own limits, and its own record of what happened.
## Guidance and limits [#guidance-and-limits]
A mandate keeps two things separate: what you want done, and what the agent is
allowed to do. The objective and guidance tell the agent what you want, and the
model reads them. The authority sets the limits, and Shadway enforces them on every
action before it runs.
Putting "never spend more than $8,000" in the guidance doesn't stop anything.
Setting `authority.spend.maximum` does. If something has to be true, put it in the
authority. If it's just a preference, put it in the guidance.
`deadline` and `expiresAt` work the same way. The deadline tells the agent when the
job is due, which affects how it plans, not what it's allowed to do. `expiresAt` is
a hard cutoff: once it passes, no action runs and the mandate ends `expired`.
## Create a mandate [#create-a-mandate]
```ts title="create-mandate.ts"
const mandate = await shadway.mandates.create({
agent: agent.id,
objective:
"Buy 200 units of part #48-221 for less than $8,000 total, " +
"including shipping.",
guidance: [
"Compare at least two suppliers before ordering.",
"Ask for written confirmation of agreed terms.",
],
authority: {
capabilities: ["email.send", "mailbox.read_thread", "procurement.create_po"],
spend: {
maximum: { amount: 799_999, currency: "usd" },
},
maximumActions: 40,
capabilityLimits: [
{ capability: "procurement.create_po", maximumActions: 1 },
],
approval: {
requiredFor: ["procurement.create_po"],
},
successCriteria: [{ fact: "amount_minor", max: 799_999 }],
},
metadata: { purchaseRequest: "PR-1042" },
});
```
```go title="create_mandate.go"
maxActions := int64(40)
poLimit := int64(1)
maxAmount := float64(799_999)
mandate, err := client.Mandates.Create(ctx, shadway.MandateCreateParams{
Agent: agent.ID,
Objective: "Buy 200 units of part #48-221 for less than $8,000 total, " +
"including shipping.",
Guidance: []string{
"Compare at least two suppliers before ordering.",
"Ask for written confirmation of agreed terms.",
},
Authority: &shadway.Authority{
Capabilities: []string{
"email.send", "mailbox.read_thread", "procurement.create_po",
},
Spend: &shadway.SpendAuthority{
Maximum: shadway.Money{Amount: 799_999, Currency: "usd"},
},
MaximumActions: &maxActions,
CapabilityLimits: []shadway.CapabilityLimit{
{Capability: "procurement.create_po", MaximumActions: &poLimit},
},
Approval: &shadway.ApprovalAuthority{
RequiredFor: []string{"procurement.create_po"},
},
SuccessCriteria: []shadway.SuccessCriterion{
{Fact: "amount_minor", Max: &maxAmount},
},
},
Metadata: &shadway.Metadata{"purchaseRequest": "PR-1042"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(mandate.ID, mandate.Status)
```
Money amounts are in the smallest unit, with a lowercase currency code: 799,999 in
`usd` is $7,999.99. `procurement.create_po` is an example of a custom capability.
Only grant capability names that exist in your workspace, and use the full action
name like `email.send`. A bare family name like `email` can't be granted.
## Authority fields [#authority-fields]
Every field is optional. Leave one out and that limit doesn't apply.
A mandate can only grant capabilities the agent already has. Rules can only
restrict what those capabilities allow. An amendment replaces the whole set of
limits, and every version is kept. Nothing a mandate says can give the agent more
than it started with.
**Budgets.** `spend.maximum` caps total spending. Any action that spends money has
to say how much, and if it doesn't, Shadway blocks it rather than treating it as
zero. `spend.approvalThreshold` (which has to be below the maximum) makes any
single action above that amount wait for approval. `maximumActions` limits how many
actions with an effect the agent can take. `maximumReadActions` is a separate
budget for read-only actions, so checking a delivery status doesn't count against
it.
**Per-capability limits.** `capabilityLimits` sets a limit on a single capability:
a maximum count, a rate like `{ maximum: 3, interval: "1h" }`, or both. Once the
limit is hit, Shadway blocks the action and the agent works around it.
**Boundaries.** `prohibitedActions` lists actions that can never run under this
mandate. Shadway checks this list first, so no other rule can turn them back on.
`quietHours` blocks actions during set hours in a given timezone. `rules` limit the
values an action can use. For example, a rule can allow `procurement.create_po`
only when `quantity` is 500 or less. A rule can only tighten a capability the
mandate already grants, and an action that leaves out a limited value is blocked.
**Approvals.** `approval.requiredFor` lists actions that need a person to sign off
every time, no matter the amount. See [Approvals](/docs/concepts/approvals).
**Done.** `successCriteria` set what counts as finished, based on confirmed facts.
See [Verification](/docs/concepts/verification).
## Mandate statuses [#mandate-statuses]
Shadway sets the status, not you. If a wait for a person times out, the mandate
ends up `paused`, not `failed`. You can resume it, and it keeps everything it
learned.
## Pause, resume, and cancel [#pause-resume-and-cancel]
These are commands you send, not statuses you set. Each one only works from certain
statuses. Send one that doesn't apply and you get a conflict error instead of it
being ignored.
```ts title="control.ts"
const work = shadway.mandate(mandate.id);
await work.pause({ reason: "Procurement is reviewing the specification." });
await work.resume({ reason: "The specification is confirmed." });
await work.cancel({ reason: "The part is no longer needed." });
```
```go title="control.go"
work := client.Mandate(mandate.ID)
pauseReason := "Procurement is reviewing the specification."
if _, err := work.Pause(ctx, &shadway.MandateCommandParams{Reason: &pauseReason}); err != nil {
log.Fatal(err)
}
resumeReason := "The specification is confirmed."
if _, err := work.Resume(ctx, &shadway.MandateCommandParams{Reason: &resumeReason}); err != nil {
log.Fatal(err)
}
cancelReason := "The part is no longer needed."
if _, err := work.Cancel(ctx, &shadway.MandateCommandParams{Reason: &cancelReason}); err != nil {
log.Fatal(err)
}
```
These are three separate actions. Send the one that fits what you decided, not all
three in a row. Pause stops new work at the next safe stopping point. The status
changes once the work actually stops, not the moment you call it, and anything
already sent to an outside system isn't undone. Cancel works from any non-final
status and stops further work, but it doesn't undo an email already sent or an
order already placed.
## Change the limits with an amendment [#change-the-limits-with-an-amendment]
You don't edit a mandate's limits in place. You send an amendment, which replaces
the whole set of limits, records who changed them and why, and takes effect at the
mandate's next step, even if it's in the middle of running.
```ts title="amend.ts"
const current = await work.get();
const amendment = await work.amend({
authority: {
...current.authority,
spend: { maximum: { amount: 899_999, currency: "usd" } },
},
reason: "Approved budget increase for expedited shipping.",
expectedVersion: current.authorityVersion,
});
console.log(amendment.version); // The new authority version.
```
```go title="amend.go"
current, err := work.Get(ctx)
if err != nil {
log.Fatal(err)
}
authority := current.Authority
authority.Spend = &shadway.SpendAuthority{
Maximum: shadway.Money{Amount: 899_999, Currency: "usd"},
}
amendment, err := work.Amend(ctx, shadway.MandateAmendmentCreateParams{
Authority: authority,
Reason: "Approved budget increase for expedited shipping.",
ExpectedVersion: current.AuthorityVersion,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(amendment.Version) // The new authority version.
```
`expectedVersion` keeps two edits from clashing: if someone amended the mandate
after you read it, yours is rejected with a conflict instead of quietly overwriting
theirs. Amendments can't be changed after the fact. The full history stays, and
every action records which version of the limits it ran under.
## Continue building [#continue-building]
How a mandate runs, survives failure, and resumes.
Boundaries a mandate hits that need a human decision.
# Verification (/docs/concepts/verification)
Before Shadway calls a job done, it confirms what actually happened out in the
world. It doesn't take the agent's word for it, and it doesn't trust a "success"
response from an API. It checks the real result.
A `200 OK` from an email API isn't proof the email arrived. A supplier saying "it
shipped" isn't a tracking record showing it did. Shadway keeps the claim and the
proof apart, and a mandate is done only when the proof backs up the claim.
## Facts [#facts]
A fact is something Shadway knows about the job, like the price of an order or
whether it was delivered. Shadway writes facts as the work happens. There is no
way to write a fact through the API, so no one can edit the record to make a job
look finished.
Each fact has a status that says how sure Shadway is:
* `verified` and `derived`: confirmed.
* `observed` and `asserted`: seen or reported, but not confirmed.
* `stale`, `conflicted`, and `contradicted`: out of date, or in conflict with
something else Shadway knows.
* `unknown`: a gap Shadway is tracking.
Every fact also records where it came from: an action the agent took, an event, a
piece of evidence, or a check. No fact appears without a source.
```ts title="read-facts.ts"
for await (const fact of shadway.mandate(mandateId).facts.list()) {
console.log(fact.key, fact.value, fact.status);
}
```
```go title="read_facts.go"
for fact, err := range client.Mandate(mandateID).Facts(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println(fact.Key, fact.Value, fact.Status)
}
```
## Evidence [#evidence]
Evidence is the actual source behind a fact: a line in an email, a field in an API
response, a page of a document. It's the real quote, not a summary. Each piece
records where it is, a short excerpt, and how strong it is: who produced it,
whether Shadway confirmed where it came from, and whether it's firsthand.
Shadway doesn't rank sources on a fixed scale. It weighs each one against the
specific claim. A contract term is settled only by a signed agreement, and an
account balance only by an account record. A supplier's email saying a package
shipped proves the supplier said so, not that it shipped.
```ts title="read-evidence.ts"
for await (const evidence of shadway.mandate(mandateId).evidence.list()) {
console.log(evidence.title, evidence.quality.sourceClass, evidence.excerpt);
}
```
```go title="read_evidence.go"
for evidence, err := range client.Mandate(mandateID).Evidence(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println(evidence.Title, evidence.Quality.SourceClass, evidence.Excerpt)
}
```
Evidence points to artifacts: the documents, emails, and records the mandate
fetched or received, saved exactly as they arrived. Downloads use short-lived
signed links, and the record never stores a link with credentials in it.
## Issues, assertions, and information needs [#issues-assertions-and-information-needs]
Longer jobs also track their open questions. An issue is a question the work needs
to answer, like "Will the supplier honor the quoted price?" An assertion is a
claim someone made about it. An information need is something the agent still has
to find out. Issue states only move forward: a reply that acknowledges a question
without answering it doesn't count as answered. Shadway tracks who said something
separately from how well it holds up. These are read-only. Use them to see what
the agent is still working out, not just what it has concluded.
## Success criteria and completion [#success-criteria-and-completion]
Success criteria are the conditions you set for "done": for example, the order
cost under $8,000 and was delivered. When the agent says the job is done, Shadway
doesn't take its word. It checks each criterion against confirmed facts, and marks
the mandate `completed` only if they all hold.
A criterion passes only on a fact that exists, is confirmed, and is within
range. A missing fact fails it. A reported-but-unconfirmed fact fails it. If any
criterion fails, the mandate ends failed and records what didn't
match. A mandate never completes just because the agent said so.
So a mandate that has to confirm delivery can't finish on the agent's say-so. It
has to actually confirm it, through an integration whose results Shadway trusts,
or by reading it back from a system of record.
## When an outcome is unclear [#when-an-outcome-is-unclear]
Sometimes a failure is really an unknown. The request to place an order times out,
but the order might have gone through anyway. If the integration can check,
Shadway checks: it looks up what actually happened and records both the timed-out
attempt and the answer.
If that check also fails, the outcome stays unknown. Shadway does not mark it a
failure, and it never assumes it's safe to try the action again. See
[Execution](/docs/concepts/execution) for how this works with retries.
## Continue building [#continue-building]
Read the outcome, facts, and evidence when work finishes.
Where a capability declares how its results are verified.
# Waiting (/docs/concepts/waiting)
A mandate often has to wait: for a reply, for a scheduled time, or for a person to
decide.
A mandate spends most of its time waiting. A supplier replies the next day, an
approval takes a few hours, a delivery confirms at the end of the week. While a
mandate waits, Shadway saves it and stops running it. It uses no model calls and
costs nothing until something wakes it up: a reply, a set time, or a person's
decision.
## The four kinds of wait [#the-four-kinds-of-wait]
The status tells you what the mandate is waiting for. `waiting_external` means it
is waiting for a reply, a timer, or free capacity to run. `needs_approval` means
it is waiting for a person to approve an action or answer a question.
## The agent can only wait for real replies [#the-agent-can-only-wait-for-real-replies]
The agent can only wait for a reply to something it actually did. If it sent an
email, it can wait for the reply. If it placed a call, it can wait for the call to
finish. Only the matching reply wakes the mandate. An unrelated webhook, or an
approval about something else, never wakes a mandate that is waiting on a
supplier.
Shadway checks this when the wait starts. Say the agent tries to wait for a reply
to an email, but the email was blocked and never went out. There is nothing that
could reply, so Shadway refuses the wait and the agent has to plan again. A
mandate can never sit waiting for a reply that can't come.
If a reply arrives in the split second between the agent finishing an action
and the mandate going to sleep, Shadway holds onto it and delivers it. The wait
ends right away instead of missing the reply.
## What happens on a timeout [#what-happens-on-a-timeout]
Every wait has a time limit. What happens when it runs out depends on what the
mandate was waiting for:
* **Waiting for a reply or a timer**: the agent wakes up without the reply and
decides what to do next. It can follow up, try someone else, or report what it
knows. The default limit for a reply is 24 hours.
* **Waiting for a person**: if an approval or a question times out, the mandate
stops with the status `paused` and keeps everything it has learned. Resume it
whenever someone is ready. If a decision arrives after the approval has expired,
Shadway refuses it and does not run the action.
## See what a mandate is waiting for [#see-what-a-mandate-is-waiting-for]
`currentState.waitingOn` describes the current wait in plain words. Show it in
your app so people know why the work is waiting.
```ts title="waiting-on.ts"
const current = await shadway.mandate(mandateId).get();
if (current.status === "waiting_external" || current.status === "needs_approval") {
console.log(current.currentState.waitingOn ?? "Waiting");
}
```
```go title="waiting_on.go"
current, err := client.Mandate(mandateID).Get(ctx)
if err != nil {
log.Fatal(err)
}
switch current.Status {
case shadway.MandateStatusWaitingExternal, shadway.MandateStatusNeedsApproval:
if current.CurrentState.WaitingOn != nil {
fmt.Println(*current.CurrentState.WaitingOn)
}
}
```
A mandate with the status `paused` needs a command to start again. See
[pause, resume, and cancel](/docs/concepts/mandates#pause-resume-and-cancel). A
waiting mandate needs nothing from you: it starts again on its own when the
reply, timer, or decision arrives.
## What waiting costs [#what-waiting-costs]
A waiting mandate uses no model calls. A mandate that waits three weeks and works
for six minutes costs you six minutes. You can see this in the record: there are
no model calls between the action and the reply.
## Continue building [#continue-building]
The wait that ends with a human decision.
How the loop around the waits stays durable.
# Handle an approval (/docs/guides/handle-approvals)
When a mandate reaches an action that needs a person, it waits. This guide builds
the other side of that wait: finding pending approvals, presenting one for
review, and recording the decision safely.
An approval flow has three jobs: notice that a decision is needed, show the
person exactly what would run, and submit their decision exactly once. Shadway
makes the third job safe to retry.
## Find pending approvals [#find-pending-approvals]
For an inbox-style screen, list pending approvals across the workspace. Filter
by mandate when the screen is scoped to one piece of work.
```ts title="pending.ts"
import { Shadway } from "shadway";
const shadway = new Shadway();
for await (const approval of shadway.approvals.list({ status: ["pending"] })) {
console.log(approval.id, approval.mandate, approval.question);
}
```
```go title="pending.go"
client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
for approval, err := range client.Approvals.List(ctx, &shadway.ApprovalListParams{
Status: []shadway.ApprovalStatus{shadway.ApprovalStatusPending},
}) {
if err != nil {
log.Fatal(err)
}
fmt.Println(approval.ID, approval.Mandate, approval.Question)
}
```
To be notified instead of polling, subscribe a
[webhook](/docs/guides/verify-webhooks) to `mandate.approval_requested` and
`approval.resolved`. The first tells you a decision is needed. The second tells
you it no longer is, because it was decided elsewhere, expired, or the mandate
was canceled.
## Present the action [#present-the-action]
Show the person what the approval carries: the question, the exact action, and
the deadline. Don't paraphrase the action. The screen exists so that a person
sees exactly what would run.
```ts title="present.ts"
const approval = await shadway.approvals.get(approvalId);
render({
question: approval.question,
capability: approval.action.capability,
consequence: approval.action.consequence,
arguments: approval.action.arguments, // Already redacted; safe to display.
expiresAt: approval.expiresAt,
});
```
```go title="present.go"
approval, err := client.Approvals.Get(ctx, approvalID)
if err != nil {
log.Fatal(err)
}
render(View{
Question: approval.Question,
Capability: approval.Action.Capability,
Consequence: approval.Action.Consequence,
Arguments: approval.Action.Arguments, // Already redacted; safe to display.
ExpiresAt: approval.ExpiresAt,
})
```
An approval whose `action.capability` is `input.provide` is the agent asking a
person for information rather than permission. Render its fields as a small
form and pass the answers as `inputs` on approve. One screen handles both.
## Record the decision [#record-the-decision]
Authenticate the person and check their access to this mandate in your
application first. Shadway records the decision your application submits.
```ts title="decide.ts"
import { ShadwayConflictError } from "shadway";
async function submitDecision(
approvalId: string,
decision: "approve" | "reject",
reason: string,
) {
try {
if (decision === "approve") {
return await shadway.approvals.approve(approvalId, { reason });
}
return await shadway.approvals.reject(approvalId, { reason });
} catch (error) {
if (error instanceof ShadwayConflictError) {
// Already decided the other way, expired, or otherwise not pending.
return shadway.approvals.get(approvalId);
}
throw error;
}
}
```
```go title="decide.go"
func submitDecision(ctx context.Context, client *shadway.Client, approvalID, decision, reason string) (*shadway.Approval, error) {
var approval *shadway.Approval
var err error
if decision == "approve" {
approval, err = client.Approvals.Approve(ctx, approvalID, &shadway.ApprovalApproveParams{Reason: &reason})
} else {
approval, err = client.Approvals.Reject(ctx, approvalID, &shadway.ApprovalRejectParams{Reason: &reason})
}
if shadway.IsConflict(err) {
// Already decided the other way, expired, or otherwise not pending.
return client.Approvals.Get(ctx, approvalID)
}
return approval, err
}
```
The conflict handling is what makes double-clicks and retries safe. Repeating
the same decision returns the original result. Only a contradicting decision
conflicts, and then the read shows what actually stands. A decision that lands
after expiry marks the approval `expired` instead of executing. Show that
outcome to the person rather than pretending it went through.
## Wait inline instead [#wait-inline-instead]
When your application drives one mandate and wants to pause at its decision
point, skip the inbox and wait directly:
```ts title="wait-inline.ts"
const approval = await shadway.mandate(mandateId).waitForApproval({ timeout: "10m" });
if (approval) {
// Present it, collect the decision, submit as above.
} else {
// The mandate reached a terminal status without asking.
}
```
```go title="wait_inline.go"
approval, err := client.Mandate(mandateID).WaitForApproval(ctx, shadway.WaitOptions{
Timeout: 10 * time.Minute,
})
if err != nil {
log.Fatal(err) // A timeout means no approval yet; check shadway.IsTimeout.
}
if approval != nil {
// Present it, collect the decision, submit as above.
} else {
// The mandate reached a terminal status without asking.
}
```
Remember what a decision means: approving authorizes the one reviewed action,
inside the mandate's unchanged limits, and rejecting refuses that action, not
the mandate. If the whole task should stop,
[cancel the mandate](/docs/concepts/mandates#pause-resume-and-cancel).
## Continue building [#continue-building]
Where approvals come from, and the guarantees behind decisions.
Get told when a decision is needed, instead of polling.
# Guides (/docs/guides)
Guides show how to do one thing well. Each one uses the official SDKs, keeps the
example small and realistic, and covers the problems you'll actually run into:
restarts, retries, and messages that show up twice.
Follow work live, resume after a restart, and recover gaps.
Block until work finishes, survive timeouts, and read the outcome.
Build the screen where a person reviews and decides.
Prove a delivery came from Shadway before you trust it.
If you're new to the model these guides build on, start with
[Concepts](/docs/concepts).
# Stream a mandate's events (/docs/guides/stream-events)
Events are a running record of a mandate: every status change, action, approval,
and finding, as it happens. This guide streams one mandate's events, keeps working
through your own restarts, and handles duplicates and gaps.
## Open the stream [#open-the-stream]
```ts title="stream.ts"
import { Shadway, isKnownEvent } from "shadway";
const shadway = new Shadway();
const controller = new AbortController();
for await (const event of shadway
.mandate(mandateId)
.events.stream({ signal: controller.signal })) {
if (!isKnownEvent(event)) continue;
console.log(event.sequence, event.type);
if (
event.type === "mandate.status_changed" &&
["completed", "failed", "canceled", "expired"].includes(event.data.mandate.status)
) {
break;
}
}
```
```go title="stream.go"
client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
for event, err := range client.Mandate(mandateID).Stream(ctx, shadway.EventStreamOptions{}) {
if err != nil {
log.Fatal(err)
}
if !event.IsKnown() {
continue // A future event type this SDK version does not model yet.
}
fmt.Println(event.Sequence, event.Type)
if data, ok := event.Data.(*shadway.MandateStatusChangedData); ok {
switch data.Mandate.Status {
case shadway.MandateStatusCompleted, shadway.MandateStatusFailed,
shadway.MandateStatusCanceled, shadway.MandateStatusExpired:
return
}
}
}
```
The stream reconnects and deduplicates by event ID on its own. Ending the stream
stops observation only. It never affects the mandate. New event types appear
over time, so always skip what you don't recognize instead of failing on it.
## Handle specific event types [#handle-specific-event-types]
Each event type carries a typed payload. Narrow before you touch `data`.
```ts title="narrow.ts"
for await (const event of shadway.mandate(mandateId).events.stream({ signal })) {
if (!isKnownEvent(event)) continue;
switch (event.type) {
case "mandate.status_changed":
console.log(event.data.previousStatus, "->", event.data.mandate.status);
break;
case "mandate.approval_requested":
console.log("needs a decision:", event.data.approval.question);
break;
case "capability.execution_failed":
console.log("failed:", event.data.execution.error?.message);
break;
}
}
```
```go title="narrow.go"
for event, err := range client.Mandate(mandateID).Stream(ctx, shadway.EventStreamOptions{}) {
if err != nil {
log.Fatal(err)
}
switch data := event.Data.(type) {
case *shadway.MandateStatusChangedData:
fmt.Println(data.PreviousStatus, "->", data.Mandate.Status)
case *shadway.ApprovalEventData:
fmt.Println("needs a decision:", data.Approval.Question)
case *shadway.CapabilityExecutionEventData:
if event.Type == shadway.EventTypeCapabilityExecutionFailed && data.Execution.Error != nil {
fmt.Println("failed:", data.Execution.Error.Message)
}
}
}
```
## Resume after a restart [#resume-after-a-restart]
Persist the last event ID you processed. A database row per mandate is enough.
Pass it when you reconnect, and the stream replays everything after that point,
so nothing that happened while you were down is skipped.
```ts title="resume.ts"
let lastEventId = await loadCheckpoint(mandateId); // Your storage.
for await (const event of shadway
.mandate(mandateId)
.events.stream({ signal, ...(lastEventId ? { lastEventId } : {}) })) {
if (!isKnownEvent(event)) continue;
await handle(event); // Process before checkpointing.
await saveCheckpoint(mandateId, event.id);
lastEventId = event.id;
}
```
```go title="resume.go"
lastEventID := loadCheckpoint(mandateID) // Your storage.
for event, err := range client.Mandate(mandateID).Stream(ctx, shadway.EventStreamOptions{
LastEventID: lastEventID,
}) {
if err != nil {
log.Fatal(err)
}
if !event.IsKnown() {
continue
}
handle(event) // Process before checkpointing.
saveCheckpoint(mandateID, event.ID)
}
```
Delivery is at least once: after a reconnect you may see an event you already
processed. Make handling idempotent. Keying your side effects by `event.id` is
the simplest way.
## Event order and gaps [#event-order-and-gaps]
`sequence` goes up within a mandate and never repeats, so use it to put events
in order and to notice one missing. It is not gap-free, so do not treat a
skipped number as data loss by itself. When you need a complete picture, list
the mandate's events, which return in ascending sequence order:
```ts title="backfill.ts"
for await (const event of shadway.mandate(mandateId).events.list()) {
console.log(event.sequence, event.type);
}
```
```go title="backfill.go"
for event, err := range client.Mandate(mandateID).Events(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println(event.Sequence, event.Type)
}
```
There is also a workspace-wide stream (`shadway.events` / `client.Events`) that
carries every mandate's events in occurrence order. Use it when one consumer
feeds your own system. The per-mandate patterns apply there too, except that
ordering across mandates has no sequence. Deduplicate by event ID and order
events within each mandate by its `sequence`.
For delivery to your servers without holding a connection open, use
[webhooks](/docs/guides/verify-webhooks). They carry the same event objects.
## Continue building [#continue-building]
When you only need the ending, not the story.
The same events, pushed to your servers.
# Verify webhooks (/docs/guides/verify-webhooks)
Webhooks push the same events to your servers that you'd otherwise stream. Anyone
can POST JSON to a public URL, so before you act on one, check that it really came
from Shadway, arrived recently, and wasn't changed on the way. The SDKs do the
whole check in one call.
## Create an endpoint [#create-an-endpoint]
Endpoints must be HTTPS. Subscribe to specific event types, or `"*"` for all of
them.
```ts title="create-endpoint.ts"
import { Shadway } from "shadway";
const shadway = new Shadway();
const created = await shadway.webhooks.endpoints.create({
url: "https://example.com/shadway/webhooks",
subscribedEvents: ["mandate.status_changed", "mandate.approval_requested"],
});
console.log(created.endpoint.id);
console.log(created.secret); // Shown only in this response. Store it now.
```
```go title="create_endpoint.go"
client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
created, err := client.Webhooks.Endpoints.Create(ctx, shadway.WebhookEndpointCreateParams{
URL: "https://example.com/shadway/webhooks",
SubscribedEvents: []shadway.EventType{
shadway.EventTypeMandateStatusChanged,
shadway.EventTypeMandateApprovalRequested,
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(created.Endpoint.ID)
fmt.Println(created.Secret) // Shown only in this response. Store it now.
```
The secret (`whsec_...`) appears once, in the create response. Store it in your
secrets manager. You can't read it back later, only rotate it.
## Verify every delivery [#verify-every-delivery]
Verify against the raw request body: the exact bytes, before any JSON parsing.
A body your framework has parsed and re-serialized will not match the signature.
```ts title="handler.ts"
import { Shadway, WebhookVerificationError } from "shadway";
const shadway = new Shadway();
const secret = process.env.SHADWAY_WEBHOOK_SECRET!;
// Works in any runtime with Request/Response (Node 20+, workers, edge).
export async function handleWebhook(request: Request): Promise {
const body = new Uint8Array(await request.arrayBuffer());
try {
const delivery = await shadway.webhooks.verify(body, request.headers, secret);
await processOnce(delivery.id, delivery.event); // Dedupe by delivery ID.
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response(null, { status: 400 });
}
throw error;
}
}
```
```go title="handler.go"
var secret = os.Getenv("SHADWAY_WEBHOOK_SECRET")
func handleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
delivery, err := shadway.VerifyWebhook(body, r.Header, secret, nil)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
processOnce(delivery.ID, delivery.Event) // Dedupe by delivery ID.
w.WriteHeader(http.StatusNoContent)
}
```
Return a 2xx quickly. Do the real work after responding, or on a queue. A
non-2xx response, including a timeout, means Shadway retries the delivery with
backoff for up to an hour per attempt interval.
A delivery can arrive twice, and later events can arrive before earlier ones.
Deduplicate by the delivery ID, and use each event's mandate
sequence to order events within a mandate. Never assume a webhook
is the first or only time you see an event.
If Express or a similar framework handles your routes, register the raw-body
middleware for the webhook path (`express.raw({ type: "application/json" })`)
so parsing doesn't consume the exact bytes first.
## What verification checks [#what-verification-checks]
Each delivery carries two headers:
* `Shadway-Webhook-Id`: a stable delivery ID, the same across retries of one
delivery. This is your deduplication key.
* `Shadway-Signature`: `t=,v1=`, an HMAC-SHA256 of the
delivery ID, the timestamp, and the raw body, keyed by your endpoint secret.
The SDK recomputes the signature in constant time, accepts any matching `v1`
value (there can be more than one during secret rotation), and rejects
timestamps older than five minutes to stop replays. You get either a verified
event object or a typed verification error.
## Rotate the secret [#rotate-the-secret]
Rotation issues a new secret while deliveries signed with the previous one keep
verifying for a short overlap window. Deploy the new secret within it.
```ts title="rotate.ts"
const rotated = await shadway.webhooks.endpoints.rotateSecret(endpointId);
console.log(rotated.secret); // Deploy this; the old secret expires shortly.
```
```go title="rotate.go"
rotated, err := client.Webhooks.Endpoints.RotateSecret(ctx, endpointID)
if err != nil {
log.Fatal(err)
}
fmt.Println(rotated.Secret) // Deploy this; the old secret expires shortly.
```
During the overlap, the signature header carries a `v1` value for each active
secret, and verification with either succeeds. Rotation never drops
deliveries.
## Continue building [#continue-building]
The pull-based view of the same events, with resume and backfill.
What to build when the webhook says a decision is needed.
# Wait for a result (/docs/guides/wait-for-results)
Sometimes you only need to know whether the work finished and what it found. This
guide waits for a mandate to end, handles waits that outlast your process, and
reads the result along with its evidence.
## Wait for a terminal status [#wait-for-a-terminal-status]
`wait` returns when the mandate reaches `completed`, `failed`, `canceled`, or
`expired`, including work that already finished before you called it.
```ts title="wait.ts"
import { Shadway, ShadwayTimeoutError } from "shadway";
const shadway = new Shadway();
const work = shadway.mandate(mandateId);
try {
const result = await work.wait({ timeout: "30m" });
console.log(result.status, "-", result.currentState.summary);
} catch (error) {
if (!(error instanceof ShadwayTimeoutError)) throw error;
console.log("Still working. Reconnect later with mandate ID:", work.id);
}
```
```go title="wait.go"
client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
work := client.Mandate(mandateID)
result, err := work.Wait(ctx, shadway.WaitOptions{Timeout: 30 * time.Minute})
if err != nil {
if shadway.IsTimeout(err) {
fmt.Println("Still working. Reconnect later with mandate ID:", work.ID())
return
}
log.Fatal(err)
}
fmt.Println(result.Status, "-", result.CurrentState.Summary)
```
A timeout ends only your local wait. The mandate keeps running on the server,
and waiting again later, from the same process or a different one, picks it up
wherever it is. With no timeout set, the wait runs until the mandate ends or
you cancel the call.
## Reconnect across restarts [#reconnect-across-restarts]
Real work runs for days. Your process doesn't have to. The pattern is to store
the mandate ID when you create the work, and reconnect by ID whenever you need
the answer:
```ts title="reconnect.ts"
// In the request that starts the work:
const mandate = await shadway.mandates.create({ agent: agentId, objective });
await store.save({ jobId, mandateId: mandate.id });
// In a worker, any time later. Creating a handle starts nothing:
const { mandateId } = await store.load(jobId);
const result = await shadway.mandate(mandateId).wait({ timeout: "5m" });
```
```go title="reconnect.go"
// In the request that starts the work:
mandate, err := client.Mandates.Create(ctx, shadway.MandateCreateParams{
Agent: agentID, Objective: objective,
})
if err != nil {
log.Fatal(err)
}
store.Save(jobID, mandate.ID)
// In a worker, any time later. Creating a handle starts nothing:
mandateID := store.Load(jobID)
result, err := client.Mandate(mandateID).Wait(ctx, shadway.WaitOptions{
Timeout: 5 * time.Minute,
})
if err != nil {
log.Fatal(err) // Or check shadway.IsTimeout and try again later.
}
fmt.Println(result.Status)
```
If your process does more than wait, prefer short timeouts in a retry loop over
one long blocking call. Each attempt is cheap, and checking often has no effect
on the mandate.
## Read the outcome [#read-the-outcome]
`completed` means every success criterion checked out against verified facts.
For any other status, read what the work recorded to understand what happened.
```ts title="outcome.ts"
const result = await work.wait({ timeout: "30m" });
if (result.status === "completed") {
for await (const fact of work.facts.list()) {
console.log("fact:", fact.key, "=", fact.value, `(${fact.status})`);
}
for await (const evidence of work.evidence.list()) {
console.log("evidence:", evidence.title, "-", evidence.excerpt);
}
} else {
console.log("Ended", result.status, "-", result.currentState.summary);
for await (const issue of work.issues.list()) {
console.log("open question:", issue.question, `(${issue.state})`);
}
}
```
```go title="outcome.go"
result, err := work.Wait(ctx, shadway.WaitOptions{Timeout: 30 * time.Minute})
if err != nil {
log.Fatal(err)
}
if result.Status == shadway.MandateStatusCompleted {
for fact, err := range work.Facts(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Printf("fact: %s = %v (%s)\n", fact.Key, fact.Value, fact.Status)
}
for evidence, err := range work.Evidence(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println("evidence:", evidence.Title, "-", evidence.Excerpt)
}
} else {
fmt.Println("Ended", result.Status, "-", result.CurrentState.Summary)
for issue, err := range work.Issues(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Printf("open question: %s (%s)\n", issue.Question, issue.State)
}
}
```
Facts and evidence are how you trust a completion, and issues are how you
diagnose anything else. For how the statuses on facts decide what "completed"
means, see [Verification](/docs/concepts/verification).
## Wait for an approval instead [#wait-for-an-approval-instead]
If you're waiting for a decision point rather than the end of the work, use
`waitForApproval`. It returns the pending approval, or nothing if the mandate
ends without asking. The full pattern is in
[Handle an approval](/docs/guides/handle-approvals).
## Continue building [#continue-building]
When you want the story as it happens, not only the ending.
What completed actually promises.