Oct 28, 20268 min read

How to Have an AI Agent Build a CSV Import Tool on AWS

Ops teams import customer lists, price sheets and backfills from spreadsheets every week. Here is a tested TypeScript import tool with direct uploads to S3, a background worker and a status endpoint, built by a coding agent and deployed to your own AWS account.

Most internal tools end up with an import button. Sales has a spreadsheet of new accounts, finance has a price list, and someone needs to backfill a table from an export. A coding agent writes the CSV parser in a minute, and the rest of the tool is the part that breaks in production: files too large for a request body, imports that time out halfway, and retries that insert every row twice.

This guide builds a customer import tool with a coding agent: the browser uploads the file directly to object storage, a background worker parses it and upserts the rows in a transaction, and a status endpoint reports the result. The walkthrough uses Encore as a TypeScript service for the infrastructure, and the code was tested with encore run (Encore v1.58.8). After that comes the same design built by hand with S3, SQS and Lambda.

CSV import tools at a glance

QuestionWith EncoreHand-built on AWS
File uploadBucket with signedUploadUrl, S3 on AWSS3 bucket, presigned URL code, CORS config
Background workTopic and Subscription, SNS/SQS on AWSSQS queue, Lambda trigger or a worker process
Rows land inSQLDatabase, RDS on AWSRDS in a VPC, with the worker given network access
PermissionsEncore provisions an IAM role for the serviceIAM policies for S3, SQS and secrets per function
Local testencore run with a local bucket, broker and PostgresLocalStack or a deployed dev stack

Build the import tool with Encore

Encore

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions what the code declares in your own AWS or GCP account. The agent declares the bucket, the topic and the database next to the endpoints that use them, encore run runs all three locally, and a deploy creates S3, SNS/SQS and RDS in your account.

Encore declares infrastructure in application code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in application code, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

The prompt that produced the code below:

Add a customer CSV import to the tools service. The browser should upload straight to an Encore bucket with a signed URL. After upload, queue the import on a Pub/Sub topic. The subscriber reads the file, checks the header is email,name,plan, upserts customers by email in one transaction, and records status and row count. Add a status endpoint.

The tables

-- tools/migrations/1_create_tables.up.sql CREATE TABLE customers ( email TEXT PRIMARY KEY, name TEXT NOT NULL, plan TEXT NOT NULL ); CREATE TABLE imports ( key TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', rows_imported INTEGER NOT NULL DEFAULT 0, error TEXT );

tools/db.ts declares the database with new SQLDatabase("tools", { migrations: "./migrations" }), and Encore applies the migration on startup locally and before each deploy.

Upload, queue and status endpoints

// tools/imports.ts import { api, APIError } from "encore.dev/api"; import { Topic, Subscription } from "encore.dev/pubsub"; import { Bucket } from "encore.dev/storage/objects"; import { randomUUID } from "node:crypto"; import { db } from "./db"; // Uploaded CSV files: a local bucket under `encore run`, S3 on AWS. const uploads = new Bucket("csv-uploads"); interface ImportRequested { key: string; } const importRequested = new Topic<ImportRequested>("import-requested", { deliveryGuarantee: "at-least-once", }); // Step 1: the browser asks for an upload URL and PUTs the file straight to the bucket. export const startUpload = api( { expose: true, method: "POST", path: "/imports" }, async (): Promise<{ key: string; uploadUrl: string }> => { const key = `customers/${randomUUID()}.csv`; const { url } = await uploads.signedUploadUrl(key, { ttl: 600 }); await db.exec`INSERT INTO imports (key) VALUES (${key})`; return { key, uploadUrl: url }; }, ); // Step 2: once the upload is done, the browser asks for the import to run. export const runImport = api( { expose: true, method: "POST", path: "/imports/run" }, async ({ key }: { key: string }): Promise<{ queued: boolean }> => { if (!(await uploads.exists(key))) throw APIError.notFound("upload not found"); await importRequested.publish({ key }); return { queued: true }; }, ); export const importStatus = api( { expose: true, method: "GET", path: "/imports/status" }, async ({ key }: { key: string }): Promise<{ status: string; rowsImported: number; error: string | null }> => { const row = await db.queryRow<{ status: string; rowsImported: number; error: string | null }>` SELECT status, rows_imported AS "rowsImported", error FROM imports WHERE key = ${key}`; if (!row) throw APIError.notFound("import not found"); return row; }, );

The signed URL expires after ten minutes and is scoped to one key: the browser can write that file and nothing else. The object storage docs cover signed URLs and the other bucket methods.

The worker

The subscriber runs the import outside the request, so a 50,000-row file does not hold an HTTP connection open:

// tools/imports.ts (continued) // Step 3: a subscriber parses the file and upserts rows, so a redelivery // writes the same rows again instead of duplicating them. const _ = new Subscription(importRequested, "import-customers", { handler: async ({ key }) => { const text = (await uploads.download(key)).toString("utf8"); const [header, ...lines] = text.trim().split(/\r?\n/); if (header.trim() !== "email,name,plan") { await db.exec`UPDATE imports SET status = 'failed', error = 'expected header email,name,plan' WHERE key = ${key}`; return; } await using tx = await db.begin(); let count = 0; for (const line of lines) { const [email, name, plan] = line.split(",").map((s) => s.trim()); if (!email?.includes("@")) continue; await tx.exec` INSERT INTO customers (email, name, plan) VALUES (${email}, ${name}, ${plan}) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, plan = EXCLUDED.plan`; count++; } await tx.exec`UPDATE imports SET status = 'done', rows_imported = ${count} WHERE key = ${key}`; await tx.commit(); }, });

Pub/Sub delivers at least once, which is why the rows are written with ON CONFLICT and the whole file runs in one transaction. A crash halfway rolls back, the message is redelivered, and the import runs again from the start. The split on commas fits this fixed three-column format; for files with quoted fields, ask the agent to use a CSV parsing library instead.

Testing the flow

~/tools
$ encore run$ curl -s -X POST localhost:4000/imports{"key":"customers/5b1e...csv","uploadUrl":"http://localhost:4000/..."}$ printf 'email,name,plan\na@x.com,Ann,pro\nb@x.com,Bo,free\n' > c.csv$ curl -s -X PUT --data-binary @c.csv "$UPLOAD_URL"$ curl -s -X POST localhost:4000/imports/run -d '{"key":"customers/5b1e...csv"}'{"queued":true}$ curl -s "localhost:4000/imports/status?key=customers/5b1e...csv"{"status":"done","rowsImported":2,"error":null}

Running the import a second time leaves two customers, and POST /imports/run with a key that was never uploaded returns 404. The local development dashboard shows the publish and the subscriber run in the trace for each request.

Deploying it

Connect your AWS account and push. Encore creates the S3 bucket, an SNS topic with an SQS queue for the subscription, and the RDS database in your own account, and Encore provisions an IAM role for the service. If the upload page is served from another origin, add it to global_cors in encore.app as described in the CORS docs, and set CORS on the bucket for the browser's PUT. A frontend can call the endpoints through a typed client from encore gen client.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

Good to know

Keep the imports row as the source of truth for status, so a page can poll it and show errors to the person who uploaded the file. For very large files, the worker can stream the object and commit in batches, with the upsert keeping each batch safe to replay.

The hand-built route on AWS

The same design without Encore is a set of resources someone provisions and connects:

  • An S3 bucket with a CORS rule, and code that creates presigned PUT URLs, as in the S3 presigned URL docs.
  • An SQS queue and either an S3 event notification or an API call to enqueue the import, plus a dead-letter queue.
  • A worker: a Lambda function with an SQS trigger, which needs VPC access to reach RDS, or a long-running process on ECS.
  • IAM policies for each piece: the API's role can sign URLs for the bucket and send to the queue, and the worker's role can read the bucket and receive from the queue.
  • RDS in private subnets with security groups for both the API and the worker, and LocalStack or a dev stack for testing.

An agent can write the Terraform for all of it, and every file is one more thing to review, apply and keep in sync with the application code.

How to choose

  • Encore if you want import tools to live in your backend repository, tested end to end with encore run and deployed with their bucket, queue and database to your own AWS account.
  • S3, SQS and Lambda by hand if your team already runs a standard Terraform module for this pattern and wants each resource configured individually.

Frequently asked questions

How do I have an AI agent build a CSV import tool?

Ask for three endpoints and a worker: one that returns a signed upload URL, one that queues the import once the file is uploaded, one that reports status, and a subscriber that parses the file and upserts rows in a transaction. With Encore, the agent declares the bucket, topic and database in TypeScript, tests the full flow with encore run, and on deploy Encore provisions S3, SNS/SQS and RDS in your AWS account.

Why upload the CSV directly to S3 instead of through the API?

A signed upload URL lets the browser send the file straight to the bucket, so large files do not pass through the API process or hit request size limits, and the API only handles small JSON requests.

How do I make a CSV import safe to retry?

Write rows with an upsert keyed on a natural identifier such as the email address, and run the whole file in one transaction. A redelivered message then writes the same rows again instead of duplicating them, and a failure halfway leaves no partial import.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders