Core concepts / Agents
Agents
An agent defines the model, instructions, and capabilities used by a mandate.
Each job runs as a mandate, 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.
State what is unknown.
Purchase orders · Shipment status
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
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.
Buy 200 units of part #48-221 for less than $8,000 including shipping. Confirm delivery by Friday.
The agent requests a supplier quote. Shadway checks the mandate’s permissions, then sends the email.
How the runtime works
- PlannerProposes a typed action
- GatewayChecks mandate authority
- ChannelExecutes with the provider
- VerificationVerifies the result
The ledger records actions and results. Saved waits survive process restarts. Shadway continues to handle events and timers while the agent waits.
Approval permits only the action you reviewed. Shadway checks verified facts against the mandate’s success criteria before marking it complete.
Fictional correspondence and records. Gmail and SAP represent customer-connected systems, not built-in connectors. Purchase recovery requires a configured SAP executor and checker. Shipment tracking uses a custom integration.
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 and Approvals.
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
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.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
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(", "));
}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
The supplier addresses below are placeholders. Replace them with real contacts before running the example. Supply an actual delivery deadline.
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.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
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.
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;
}
}
}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
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.");
}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.
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 });
}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
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);
}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
These are separate operator actions. Call the relevant function in response to a decision in your application. Do not run all three in sequence.
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.",
});
}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
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.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.
async function disableAgent(agentId: string) {
return shadway.agents.update(agentId, { status: "disabled" });
}
async function enableAgent(agentId: string) {
return shadway.agents.update(agentId, { status: "active" });
}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
Agent instructions describe how the agent should behave. The mandate’s objective describes what it should accomplish. Its authority defines what it may do.
Be concise with suppliers. Prefer written confirmation of terms. State what is unknown instead of guessing.
Applies to every mandateGet three suppliers to quote part #48-221 by Friday.
Applies to this mandateAn 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 for action definitions and Verification for checking results.
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:
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" },
},
});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
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.
await shadway.agents.update(agent.id, {
instructions:
"Be concise with suppliers. Record every concession. " +
"Require written confirmation of agreed terms.",
});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.