AWS AppSync GraphQL Subscriptions: Push Live Coding-Agent Progress Without Polling

2 views

Your coding-agent UI should paint “lint started → 12 files → diff ready” as it happens — not after the next GET /status lucks into a new row. AWS AppSync GraphQL subscriptions give you managed WebSocket fan-out with a GraphQL schema, auth modes (Cognito / IAM / OIDC / API key for demos only), and resolvers that publish when mutations land. Distinct from API Gateway WebSockets for multi-turn agents (you own connection tables and fan-out) and Lambda response streaming (one HTTP response, many chunks): AppSync subscriptions are long-lived push channels for progress events across tabs and clients.

⚡ TL;DR: Model AgentProgress events in GraphQL, mutate from the worker (Step Functions / Lambda / Fargate), subscribe from the UI with Cognito JWT, filter by runId/tenantId, and keep the token stream itself on Lambda streaming or Bedrock ConverseStream — subscriptions carry progress, not every token. Related: EventBridge Pipes, App Runner HTTP APIs, DynamoDB Streams session hooks, AppSync + Bedrock resolvers.

Polling vs push for agent UX

Pattern Latency feel Ops Best for
Client poll REST Jittery; wasted GETs Simple Tiny internal tools
Lambda response streaming Great TTFB for one call Function URL / invoke stream Token chunks of a single turn
API GW WebSockets Full control Connection DynamoDB, fan-out code Custom protocols, bidirectional cmds
AppSync subscriptions Push with schema Managed WS + auth Progress events + typed GraphQL clients

❌ Embedding a 60-second setInterval that hits DynamoDB for every open review tab.

Schema: progress events, not the whole transcript

graphql
# ✅ keep payloads small — UI can refetch heavy diffs via query
enum ProgressPhase {
  PLANNED
  TOOL_STARTED
  TOOL_FINISHED
  DIFF_READY
  FAILED
  COMPLETED
}

type AgentProgress @aws_cognito_user_pools @aws_iam {
  runId: ID!
  tenantId: ID!
  phase: ProgressPhase!
  toolName: String
  message: String
  percent: Int
  artifactKey: String
  ts: AWSDateTime!
}

type Mutation {
  publishAgentProgress(input: PublishProgressInput!): AgentProgress
    @aws_iam
}

type Subscription {
  onAgentProgress(runId: ID!): AgentProgress
    @aws_subscribe(mutations: ["publishAgentProgress"])
}

input PublishProgressInput {
  runId: ID!
  tenantId: ID!
  phase: ProgressPhase!
  toolName: String
  message: String
  percent: Int
  artifactKey: String
}

Authorize so a Cognito user can subscribe only to their tenant’s runId. The worker publishes with IAM (@aws_iam on the mutation). Never leave API_KEY on for production agent progress.

Publish from the agent worker

python
# ✅ Lambda / Fargate worker — IAM auth to AppSync
import json, os, datetime
from urllib import request
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

APPSYNC = os.environ["APPSYNC_URL"]  # https://xxxx.appsync-api.region.amazonaws.com/graphql

MUTATION = """
mutation Publish($input: PublishProgressInput!) {
  publishAgentProgress(input: $input) {
    runId phase toolName message percent ts
  }
}
"""

def publish_progress(run_id: str, tenant_id: str, phase: str, **extra):
    payload = {
        "query": MUTATION,
        "variables": {
            "input": {
                "runId": run_id,
                "tenantId": tenant_id,
                "phase": phase,
                "ts": datetime.datetime.utcnow().isoformat() + "Z",
                **{k: v for k, v in extra.items() if v is not None},
            }
        },
    }
    body = json.dumps(payload).encode()
    aws_req = AWSRequest(method="POST", url=APPSYNC, data=body, headers={"Content-Type": "application/json"})
    SigV4Auth(boto3.Session().get_credentials(), "appsync", os.environ["AWS_REGION"]).add_auth(aws_req)
    # ❌ publishing without SigV4 / with a shared API key in the sandbox image
    http_req = request.Request(APPSYNC, data=body, headers=dict(aws_req.headers), method="POST")
    with request.urlopen(http_req, timeout=10) as resp:
        out = json.loads(resp.read().decode())
    if out.get("errors"):
        raise RuntimeError(out["errors"])
    return out["data"]["publishAgentProgress"]

Hook publishes at phase boundaries inside Step Functions or after each tool in your runner. For EventBridge-sourced tool completions, Pipes → Lambda → publishAgentProgress keeps the worker thin.

UI subscribe (Amplify / AppSync client)

typescript
// ✅ React — subscribe filtered by runId; Cognito JWT via Amplify
import { generateClient } from "aws-amplify/api";

const client = generateClient();

const sub = client
  .graphql({
    query: /* GraphQL */ `
      subscription OnProgress($runId: ID!) {
        onAgentProgress(runId: $runId) {
          phase toolName message percent artifactKey ts
        }
      }
    `,
    variables: { runId },
  })
  .subscribe({
    next: ({ data }) => {
      const ev = data?.onAgentProgress;
      if (!ev) return;
      // paint timeline; fetch artifact from S3 only when DIFF_READY
      setEvents((prev) => [...prev, ev]);
    },
    error: (err) => console.error("subscription", err),
  });

// cleanup on unmount / run complete
// sub.unsubscribe();

Pair with Lambda response streaming for the token channel of the active turn: subscriptions = progress bus, streaming = token firehose. Mixing every token into AppSync will thrash connection quotas and cost.

AuthZ filters that actually matter

vtl
#if($context.identity.claims.get("tenant_id") != $context.args.input.tenantId)
  $util.unauthorized()
#end

Also enforce on subscriptions via enhanced filtering / resolver checks so users cannot subscribe to arbitrary runIds. Log denials; feed suspicious patterns to GuardDuty / Security Hub workflows.

Limits and failure modes

Issue Symptom Mitigation
Payload too large Mutation rejected / slow UI Store diffs in S3; send artifactKey only
Quiet periods Client thinks job died Heartbeat message every N seconds or phase ping
Fan-out storms One run, thousands of tabs Soft-cap concurrent subs; disconnect idle
Dual auth confusion Worker 401 IAM for publish, Cognito for subscribe — document both
Token spam on AppSync Cost + lag Tokens → stream; progress → subscription
bash
# ✅ CloudWatch: AppSync 4xx/5xx and connection counts
aws cloudwatch put-metric-alarm \
  --alarm-name appsync-agent-progress-5xx \
  --namespace AWS/AppSync \
  --metric-name 5XXError \
  --dimensions Name=GraphQLAPIId,Value=YOUR_API_ID \
  --statistic Sum --period 60 --threshold 5 \
  --comparison-operator GreaterThanOrEqualToThreshold \
  --evaluation-periods 1

Production checklist

  • [ ] Schema separates progress mutations from heavy queries
  • [ ] Cognito (or OIDC) for clients; IAM for workers; no prod API keys
  • [ ] Subscription filter by runId + tenant authorization
  • [ ] Diffs/artifacts in S3 (optionally Express One Zone for hot scratch)
  • [ ] Heartbeats or phase pings so UIs do not soft-deadlock
  • [ ] Tokens stay on ConverseStream / Lambda streaming — not AppSync
  • [ ] WAF / rate limits on the AppSync API endpoint
  • [ ] Trace publish latency with Application Signals
  • [ ] Document ownership tag workload=coding-agent

FAQ

Q: Can AppSync replace WebSockets entirely?
A: For typed progress and data sync, yes for many products. If you need arbitrary binary frames, server-initiated cancel races, or a non-GraphQL protocol, keep API Gateway WebSockets or App Runner.

Q: Where do I put the LLM tokens?
A: Stream them on the request path (Lambda response streaming). Use AppSync for plan/tool/diff milestones and multi-viewer fan-out.

Q: DynamoDB + Streams instead of mutation publish?
A: Possible (Streams → Lambda → noneNone? → actually Streams → Lambda that calls the mutation, or use AppSync DynamoDB resolvers). Mutations remain the clearest “this event is now public to subscribers” boundary.

Related reading

Push the progress. Stop polling hope.

Last updated on October 3, 2026

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.