Autonomous tasks

Do something, then report back

The same secure path as chat, minus the socket. Start one with an API call or an event, deliver it as many times as EventBridge feels like, and hear about the result on the bus.

The life of a task

Let’s follow one

Say your newsletter stack wants a summary of last week’s blog comments every Monday morning. Nobody is signed in, there’s no socket, and something downstream is waiting on the answer. Here’s everything that happens, in order.

  1. 0 ms

    The request goes out

    Your Lambda calls requestAgentTask. It puts one Run Agent Task event on the bus and returns a taskId right away. All it needed was events:PutEvents.

    The task carries an identity, its principal. This one is a service, so it runs as system. A task started by a signed-in person runs as their verified sub instead.

    import { requestAgentTask } from '@readysetcloud/agent/memory';
    
    const { taskId } = await requestAgentTask({
      principal: { type: 'system', id: 'newsletter' },
      request: 'Summarize last week’s blog comments as three bullets.',
      // optional: sessionId, systemPrompt, modelId, temperature, maxTokens, tools, mcpServers
    });
  2. ~20 ms

    EventBridge delivers it. Maybe twice.

    EventBridge promises at-least-once delivery, and “at least” is doing a lot of work in that sentence 😅. Two copies of your event can land on two Lambda containers at the same moment. That’s fine. The next stop is built for exactly this.

    Two deliveries of the same task race for one claim DELIVERY 1TASK ROWDELIVERY 2absentRUNNING · owned by delivery 1claim ✓claim ✗ConditionalCheckFailedreturn existing · RUNNINGbuildAgentrun turnCOMPLETEDfinishTaskemit Completed
    EventBridge delivers at least once. Both deliveries try the same conditional write on TASK#{taskId}, DynamoDB lines them up, and only one gets to build the agent and run its tools. Hit replay to watch the race.
  3. ~25 ms

    Exactly one delivery claims it

    startTask is a conditional write that moves the task row to RUNNING, but only from absent, PENDING, or FAILED. DynamoDB lines the writes up, so one delivery wins. The loser hands back the existing result or reports that it’s still running.

    Before it even gets that far, a TaskResultCache in the container catches a duplicate that lands on the same instance. That’s purely a speed-up. A cache miss is always correct.

    Task row state machine absentno row yetPENDINGcreateTaskRUNNINGone ownerCOMPLETEDoutputFAILEDerrorcreateTaskstartTask · claimstartTask · upsertfinishTaskfinishTaskretry · claim againclaim on RUNNING or COMPLETEDrefused → return existing
    Every arrow is a conditional write. startTask only claims from absent, PENDING, or FAILED, so a duplicate can never grab a task that’s running or done.
  4. ~30 ms

    The winner builds the agent and runs

    Only now does anything expensive happen. buildAgent loads the session config, connects MCP servers, and creates the assistant. A duplicate never got this far, so it cost next to nothing. Then the agent runs one buffered turn.

  5. ~8 s

    The result is recorded

    finishTask writes COMPLETED with the output, or FAILED with the error, and only if the row is still RUNNING. Completed rows stick around for 24 hours.

    StatusMeaning
    PENDINGCreated by the API so a GET sees it before the runtime picks it up. Optional.
    RUNNINGClaimed by exactly one invocation.
    COMPLETEDFinished; output holds the assistant text.
    FAILEDThe run threw; error holds the message. A retry may claim it again.
  6. ~8 s

    Everyone hears about it

    Agent Task Completed goes out no matter how it ended. Your newsletter stack listens for its taskId and carries on. Anything else that cares can listen too, without ever reading the agent’s table.

    {
      "source": "readysetcloud.agent",
      "detail-type": "Agent Task Completed",
      "detail": { "taskId": "6d1f...", "status": "COMPLETED", "output": "• ...", "principal": { "type": "system", "id": "newsletter" } }
    }
Other ways in

You don’t have to start from a Lambda

Every path ends up as the same event, landing on the same consumer, with the same result at the end. Pick based on who’s calling and whether they can wait.

Four ways in, one consumer, one result event AppPOST /agent/tasksBackendrequestAgentTaskSchedule Eventpublish laterTrigger routerrouteTriggerRun Agent Taskdefault busTASK#{taskId}sk = STATUSRunAgentTaskFunctionrunAgentTaskAgent TaskCompletedYour consumersemail · Slack · UI234156
However a task starts, it becomes the same Run Agent Task event. However it ends, it becomes the same Agent Task Completed event. Only the API path reads the task row, and that’s so it can answer inline.
  1. 1POST /agent/tasks writes a PENDING row, emits the event, and polls the row for up to ~25s: 200 with the result, or 202 with a ticket.
  2. 2A first-party backend calls requestAgentTask and moves on.
  3. 3Schedule Event publishes the same request at a later moment.
  4. 4A routeTrigger router with the default dispatcher emits one request per agent a rule concerns, carrying a trigger. (A persistent agent’s dispatcher uses its own source instead and never reaches this Lambda.)
  5. 5The consumer claims the row, runs, and records COMPLETED or FAILED.
  6. 6The result is announced on every outcome, so async consumers never depend on a waiting caller.
CallerMechanismIdentityResult
A signed-in user from an appPOST /agent/tasks { request, sessionId?, wait?, system? }The caller’s verified sub, or an allowlisted system id200 with the result when it finishes within ~25s, else 202 { taskId, status } and the event
A first-party backendrequestAgentTask() emits Run Agent TaskAsserted by the emitter (the bus is account-internal)Agent Task Completed event; optionally GET /agent/tasks/{id}
A schedule or a delayed stepSchedule Event wrapping a Run Agent TaskAs aboveAs above, at the scheduled time
A trigger router that targets the shared servicerouteTrigger → eventBridgeDispatcher() with its default sourcePer agent, from principal(agentId)As above, with a trigger on the request
What if…

Questions you’ll have eventually

…I can’t wait for an event? I want the answer now.

Call the API with wait: true. It writes a PENDING row, triggers the run, and polls that row until the REST timeout says stop. If the task finishes in about 25 seconds you get a 200 with the answer. If not, you get a 202 with the taskId, and the completion event shows up later like always.

curl -X POST "$CORE_API_URL/agent/tasks" \
  -H "Authorization: Bearer $ID_TOKEN" -H "Content-Type: application/json" \
  -d '{"request": "Draft release notes from the last 10 commits.", "wait": true}'

# 200 { "taskId": "...", "status": "COMPLETED", "output": "..." }
# 202 { "taskId": "...", "status": "RUNNING" }  → watch the event or GET /agent/tasks/{taskId}
…it should run tomorrow at 9?

Wrap the task in Schedule Event, the bus-level deferred-event primitive. Your caller doesn’t need a scheduler client or an IAM role for it. Re-emit with the same name and the schedule moves instead of doubling up.

{
  "Source": "newsletter",
  "DetailType": "Schedule Event",
  "Detail": {
    "name": "digest-2026-10-07",
    "at": "2026-10-07T09:00:00",
    "timezone": "America/Chicago",
    "whenPast": "send",
    "event": {
      "source": "readysetcloud.agent",
      "detailType": "Run Agent Task",
      "detail": { "taskId": "digest-2026-10-07", "principal": { "type": "system", "id": "newsletter" }, "request": "..." }
    }
  }
}
…the run fails halfway through?

The row ends as FAILED with the error message, and Agent Task Completed still goes out with status: "FAILED", so nobody waits forever. If the same event gets delivered again, FAILED is one of the states a delivery is allowed to claim from, so a retry gets a clean second shot.

…a person kicks it off, but it should act as a service?

Pass system on POST /agent/tasks. That’s a privilege, so it needs a grant in the SystemTaskPrincipals deploy parameter, and without one the request is refused. The person who launched it is recorded as createdBy so they can still read the result back. There’s more in Tools & permissions.

…I want to run tasks in my own compute?

Go for it. The core doesn’t come with a runtime of its own. Anything that can install the package can consume the event with runAgentTask, and you get the same claim, the same cache, and the same completion event.

import { runAgentTask, createAssistant, getSessionConfig, TaskResultCache } from '@readysetcloud/agent';

const cache = new TaskResultCache();   // module scope, reused across warm invocations

export const handler = async ({ detail }) => {
  const { taskId, principal, request, sessionId } = detail;
  return runAgentTask({
    taskId, principal, request, sessionId, cache,
    buildAgent: async () => {
      const config = sessionId ? await getSessionConfig(sessionId) : null;
      if (config && config.userId !== principal.id) throw new Error('not your session');
      const agent = createAssistant({ sessionId: sessionId ?? `task-${taskId}`, systemPrompt: config?.systemPrompt });
      return { agent, cleanup: async () => { /* disconnect MCP clients */ } };
    },
  });
};
Don’t need any of that?

runAgent: ask, validate, forget

No task row, no event, no claim. Just one call that comes back with a typed answer. It’s what you want when you’re calling a model a bunch of times inside something bigger and you need structured output instead of paragraphs.

Pass a Zod schema and the SDK makes the model answer in that shape. If the call resolves, the output is valid. Period.

import { runAgent, tool } from '@readysetcloud/agent';
import { z } from 'zod';

const grammar = await runAgent({
  input: draft,
  modelId: 'us.anthropic.claude-lite-...',          // pin a model per lens
  systemPrompt: 'You are a grammar & spelling reviewer.',
  outputSchema: z.object({ suggestions: z.array(z.object({ span: z.string(), fix: z.string() })) }),
});
grammar.output.suggestions;                           // typed, validated

// A tool-using lens, with a bounded loop and trusted context the model cannot forge:
const searchBlog = tool({
  name: 'search_blog',
  description: "Search the tenant's own posts",
  inputSchema: z.object({ query: z.string() }),
  callback: async ({ query }, context) => searchWithinTenant(context?.invocationState.tenantId as string, query),
});
const facts = await runAgent({
  input: draft,
  systemPrompt: 'Fact-check claims using the blog search tool.',
  tools: [searchBlog],
  maxIterations: 6,
  invocationState: { tenantId, sub },                 // injected by trusted code, read by handlers
});
runAgent fan-out: independent lenses, then a synthesis Draftone inputGrammar lensoutputSchema · lite modelLLM-detection lensoutputSchemaFact-check lenstools · maxIterations: 6Summary lensoutputSchemaTyped resultvalidated objectsearch_blog tooltenantId ← invocationState
Each lens is its own stateless runAgent call with its own model, schema, and tools, and they all run in parallel. Trusted context reaches the tools through invocationState. The model never sees it.
OptionEffect
outputSchemaZod schema; output becomes the validated object and structured is true.
toolsFirst-party tool()s, an McpClient, or a sub-agent.
maxIterationsCaps the tool loop (maps to the SDK’s per-invocation turn limit).
modelId, temperature, maxTokens, regionPer-call model selection.
invocationStateTrusted values threaded to every tool’s context.invocationState. Not a tool input, so the model never sees or sets it.

Need the orchestrator to be durable? Run the outer call as a task and let its lenses call runAgent. One heads-up: runAgent buffers the final answer. It doesn’t stream.