A LangGraph agent on async Lambda: delivery, cancellation, and cost
How Realysis runs its one agentic feature on Lambda + AppSync - why results are published instead of returned, how cancellation and a token budget share one kill-switch, and the guardrails that bound the one variable-cost actor.
Realysis is a real-estate intelligence tool I created for property investors - wholesalers hunting below-market deals, fix-and-flip buyers underwriting a rehab, and landlords checking whether a place will actually cash-flow. You point it at a zip code and it watches that market for you: it pulls listings, comparables, and market stats from RentCast on a schedule, tracks price and inventory shifts, and scores every new listing on its potential as a wholesale, flip, or rental play. The work it automates is the spreadsheet grind investors do by hand - ARV estimates, cap rates, rehab margins, motivated-seller signals - surfaced as a dashboard, weekly digests, and on-demand deep-dive reports.
Almost all of that intelligence is deliberately un-agentic. Deal scoring, the reports, and side-by-side zip comparisons run as deterministic Bedrock prompt chains, because the inputs are fully known when the work starts - you have the listing and the comps, and you want a structured score back. The one exception is the natural-language query - ask it “find me out-of-state owners in 85234 with flip potential” - which is the only feature that genuinely needs an agent: it has to read your sentence, decide which data to pull, fetch and filter across several steps, and adapt as it goes.
That one agentic feature runs as a LangGraph ReAct agent inside an AWS Lambda, behind AppSync. This is a profile of how it’s wired.
The agent
There’s no LangGraph Platform and no LangSmith - the agent is a library import, and the model and SDK clients live at module scope for warm reuse:
import { createReactAgent } from "@langchain/langgraph/prebuilt";
export const createAgent = (model, tools) =>
createReactAgent({ llm: model, tools, prompt: SYSTEM_PROMPT } as any);
// Module scope - reused across warm invocations
const agentModel = new ChatBedrockConverse({
model: process.env.NL_QUERY_MODEL_ID, // us.anthropic.claude-sonnet-4-6
region: process.env.AWS_REGION,
});
The lone as any bridges a CJS/ESM declaration mismatch: under moduleResolution: "nodenext", TypeScript resolves @langchain/core’s types from the CJS path and @langchain/langgraph’s from the ESM path, so structurally identical types read as incompatible. It’s a type-system papercut, not a runtime one.
Delivery: publish, don’t return
An AppSync resolver has roughly thirty seconds to respond; the agent can run for minutes. So the work is split across two Lambdas on an async pattern. A thin resolver runs synchronously, takes the request, fires the heavy worker, and immediately returns a queryId the client subscribes to:
const userId = (event.identity as AppSyncIdentityCognito).sub;
const queryId = crypto.randomUUID();
await lambdaClient.send(
new InvokeCommand({
FunctionName: process.env.NL_QUERY_LAMBDA_NAME!,
InvocationType: "Event", // async - fire and forget
Payload: encode(JSON.stringify({ query, zipCode, userId, queryId })),
}),
);
return { queryId };
The fact that shapes everything downstream: under InvocationType: "Event", nothing reads the worker’s return value. There is no caller on the other end. The worker’s result reaches the user only if the worker itself publishes it back, through an AppSync mutation signed with SigV4:
Delivery is an explicit side effect, not a return. The handler hoists userId to the top so every exit can reach the publish closure, and the comment states the constraint directly:
// ...the async-invoke return value is discarded by AWS Lambda, and
// ONLY the AppSync publish reaches the subscribing web client.
let userId: string | undefined;
What each exit owes the subscriber
Because delivery is a side effect, every way the agent can exit has to decide what it owes the person waiting. Four exits publish a message; one stays silent:
Success, a recursion-limit fallback, a generic error, and a token-budget abort all publish a string the client renders the same way - the subscription event has no separate error field, so error and status text ride home on answer. The one silent exit is the user cancel: by the time it fires, the client has already torn down its subscription, so publishing into it is pointless. Cancel is a first-class non-error exit that owes the subscriber nothing.
The liveness backstop
Publishing on every exit is necessary but not sufficient, because it assumes the publish itself succeeds. The publish call is best-effort - a throttle or an expired SSM parameter can make it fail, and the worker can’t recover once its return value is gone. So the guarantee that the spinner always stops lives in the client, as a watchdog set above the backend’s hard timeout:
const RESULT_WATCHDOG_MS = 330_000; // > nl-query Lambda timeout (300s)
const watchdog = setTimeout(() => {
setState({
status: "error",
error: "This query took too long. Please try again.",
});
}, RESULT_WATCHDOG_MS);
Two layers: the server emits a PublishFailed metric so failures are alarmable, and the client fails the query open so a lost result can’t spin forever. The client’s payload guard is deliberately lenient too - toolCalls can arrive as a JSON-encoded string over AppSync, so it’s normalized rather than required to be an array. A delivered result rejected at the door hangs the UI just as effectively as one that never arrives.
One kill-switch: cancellation and budget
The agent is the only variable-cost actor in the system. Scoring and reports are fixed-shape prompt chains, but an agent loops, and every loop spends Bedrock tokens. Two things can stop it mid-run, and they share a single AbortController.
The first is the user cancel. A watcher polls a DynamoDB CANCEL sentinel every three seconds and aborts the in-flight Bedrock call rather than letting a walked-away query run to completion:
const controller = new AbortController();
const stopCancelWatcher = startCancelWatcher({
queryId,
tableName,
onCancel: () => {
abortReason = "cancel";
controller.abort();
},
});
The second is the token budget. maxQueryTokens is enforced as a real ceiling: a callback accumulates usage across the agent’s LLM calls and trips the same controller once the running total crosses it:
const tokenBudget = createTokenBudgetGuard({
maxTokens: config.maxQueryTokens,
onExceeded: () => {
abortReason = "budget";
controller.abort();
},
});
result = await agent.invoke(
{ messages: [{ role: "user", content: query }] },
{
recursionLimit: config.maxToolCalls * 2 + 1,
signal: controller.signal,
callbacks: [tokenBudget.handler],
},
);
One controller, two triggers - but with opposite delivery obligations, which is why the handler records abortReason. A user cancel skips the publish (the client is gone); a budget abort publishes “this query needed more analysis than its budget allows,” because that user is still watching the spinner. That’s the budget abort → publish arrow sitting next to user cancel → skip in the exit diagram.
The guardrails
The ceilings that bound the one variable-cost actor:
- Recursion limit =
maxToolCalls * 2 + 1. Each tool call is two graph steps - the LLM decides, then the tool runs - so a ten-tool-call budget needs a limit of 21, not 10. - Per-invocation rebuild. The agent and its tools are rebuilt every invocation even though the model client is cached at module scope, because a per-query scoring counter lives in a tool closure. Sharing it across warm invocations would leak the count between users.
- Enforced token budget, as above.
- No silent retries. Async (
Event) invocations are retried by AWS twice by default, and a timeout at the 300-second ceiling would trigger a retry that re-runs the whole agent and re-bills Bedrock. A user query is at-most-once - the client can resubmit - so retries are disabled and the final failure goes to a DLQ:
const nlQueryLambda = new lambdaNodejs.NodejsFunction(this, "NlQueryHandler", {
retryAttempts: 0,
deadLetterQueue: nlQueryDlq,
// ...
});
The principle
All of this falls out of one fact about async Lambda invocation: your return value is debug-only. Nothing downstream reads it, so delivery is an explicit side effect - handled on every exit, and backstopped on the client because a side effect can fail. Once delivery is something you reason about per-exit, the cost controls have an obvious home: the agent is the only thing in the system that can spend without bound, so publish-discipline, the client watchdog, the step-aware recursion limit, the per-query rebuild, the enforced token budget, abort-on-cancel, and at-most-once invocation all do one job - bound the one actor that can run away, and make sure that whatever it does, the person who asked finds out.