Shadway

Core concepts / Execution

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

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.

The action pathPropose, check, execute, record
mailbox.replyReply to the supplier with a counter-offer
  1. ProposeThe model suggests a typed action
  2. CheckInside every limit
  3. ExecuteSent through the connected mailbox
  4. RecordAction and result recorded

The integration executes with its own credentials. The model never sees them.

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

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

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

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:

idempotent-create.ts
const mandate = await shadway.mandates.create(
  {
    agent: agent.id,
    objective: "Renew the domain registration before it lapses.",
  },
  { idempotencyKey: "renewal-2026-q3" },
);

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

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.

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);
  }
}

For the full streaming setup, including resuming after a restart, skipping duplicates, and filling gaps, see Stream a mandate's events.

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

On this page