# 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.