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.
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.
-
0 ms
The request goes out
Your Lambda calls
requestAgentTask. It puts oneRun Agent Taskevent on the bus and returns ataskIdright away. All it needed wasevents: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 verifiedsubinstead.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 }); -
~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.
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. -
~25 ms
Exactly one delivery claims it
startTaskis a conditional write that moves the task row toRUNNING, but only from absent,PENDING, orFAILED. 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
TaskResultCachein the container catches a duplicate that lands on the same instance. That’s purely a speed-up. A cache miss is always correct.Every arrow is a conditional write. startTaskonly claims from absent,PENDING, orFAILED, so a duplicate can never grab a task that’s running or done. -
~30 ms
The winner builds the agent and runs
Only now does anything expensive happen.
buildAgentloads 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. -
~8 s
The result is recorded
finishTaskwritesCOMPLETEDwith the output, orFAILEDwith the error, and only if the row is stillRUNNING. Completed rows stick around for 24 hours.Status Meaning PENDING Created by the API so a GETsees it before the runtime picks it up. Optional.RUNNING Claimed by exactly one invocation. COMPLETED Finished; outputholds the assistant text.FAILED The run threw; errorholds the message. A retry may claim it again. -
~8 s
Everyone hears about it
Agent Task Completedgoes out no matter how it ended. Your newsletter stack listens for itstaskIdand 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" } } }
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.
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
POST /agent/taskswrites aPENDINGrow, emits the event, and polls the row for up to ~25s:200with the result, or202with a ticket. - 2A first-party backend calls
requestAgentTaskand moves on. - 3
Schedule Eventpublishes the same request at a later moment. - 4A
routeTriggerrouter with the default dispatcher emits one request per agent a rule concerns, carrying atrigger. (A persistent agent’s dispatcher uses its own source instead and never reaches this Lambda.) - 5The consumer claims the row, runs, and records
COMPLETEDorFAILED. - 6The result is announced on every outcome, so async consumers never depend on a waiting caller.
| Caller | Mechanism | Identity | Result |
|---|---|---|---|
| A signed-in user from an app | POST /agent/tasks { request, sessionId?, wait?, system? } | The caller’s verified sub, or an allowlisted system id | 200 with the result when it finishes within ~25s, else 202 { taskId, status } and the event |
| A first-party backend | requestAgentTask() emits Run Agent Task | Asserted by the emitter (the bus is account-internal) | Agent Task Completed event; optionally GET /agent/tasks/{id} |
| A schedule or a delayed step | Schedule Event wrapping a Run Agent Task | As above | As above, at the scheduled time |
| A trigger router that targets the shared service | routeTrigger → eventBridgeDispatcher() with its default source | Per agent, from principal(agentId) | As above, with a trigger on the request |
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 */ } };
},
});
};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 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.| Option | Effect |
|---|---|
outputSchema | Zod schema; output becomes the validated object and structured is true. |
tools | First-party tool()s, an McpClient, or a sub-agent. |
maxIterations | Caps the tool loop (maps to the SDK’s per-invocation turn limit). |
modelId, temperature, maxTokens, region | Per-call model selection. |
invocationState | Trusted 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.