> For the complete documentation index, see [llms.txt](https://docs.citta.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.citta.ai/developers/custom-services/hello-world-on-aws.md).

# Hello world on AWS

From an empty laptop to a Python Custom Service running on AWS and launched from Citta - the SDK, the deploy scripts, the platform steps and the local development loop, in order.

This guide takes you from nothing to a Python service that Citta serves under your tenant's domain, opened by a signed-in user from inside an application. You build it on your laptop, deploy it to AWS Lambda behind API Gateway with the scripts that come with the SDK, register it in Citta, and launch it. Then you load the application's schema into a local database and develop against it without the platform.

You need only a user in a Citta environment and the SDK. Nothing of the platform runs on your laptop.

The guide works in two passes:

1. **Stand it up.** Deploy the SDK's hello world example unchanged, register it, pin an application to it and launch it. At the end the whole path works: browser, platform, API Gateway, your function, the application's data.
2. **Make it yours.** Download the application's schema bundle, run the service on your laptop against a local copy of the application's schemas, change the code, and redeploy.

Every step names the command to run and the line that says it worked. If you work with an AI coding assistant, read [Working with an AI coding assistant](#working-with-an-ai-coding-assistant) first.

## What you need

**In Citta:**

* A user in the account that will own the service, with the account administrator role. Registering a service and its versions is an account administration task; if you are not an administrator, an administrator does step 5 of pass 1 for you and gives you the key.
* An application group you may change and deploy in a development environment of that account. The guide creates one if you have none.

**On your laptop** (macOS or Linux; on Windows, work inside WSL 2):

| Tool                    | Version                                                                       | Used for                                  |
| ----------------------- | ----------------------------------------------------------------------------- | ----------------------------------------- |
| Python                  | 3.10 or newer, with `venv` (on Debian and Ubuntu: the `python3-venv` package) | the service and local mode                |
| Docker                  | 24 or newer, running                                                          | building the image and the local database |
| AWS CLI                 | v2                                                                            | every AWS step                            |
| `bash`, `curl`, `unzip` | any                                                                           | the scripts                               |

**The SDK** comes from PyPI as [`citta-sdk`](https://pypi.org/project/citta-sdk/); step 1 installs it.

**From your Citta administrator.** The service runs in the platform's AWS account, region and network, because the platform routes to it there and its database is reachable only from inside that network. Your administrator gives you, once:

| Value                       | What it is                                                                           |
| --------------------------- | ------------------------------------------------------------------------------------ |
| An AWS CLI profile          | a role you assume in the platform's AWS account, with the policy below               |
| `AWS_REGION`                | the platform's region                                                                |
| `ECR_REPOSITORY_URI`        | an ECR repository for your images, `<account>.dkr.ecr.<region>.amazonaws.com/<name>` |
| `LAMBDA_EXECUTION_ROLE_ARN` | the role your function runs as: basic execution and VPC access, nothing else         |
| `SUBNET_IDS`                | the platform's private subnets, comma-separated                                      |
| `SECURITY_GROUP_IDS`        | a security group that reaches the platform's database                                |
| `RESOURCE_PREFIX`           | the prefix your function names must start with, `citta-cs` unless told otherwise     |

You never create IAM roles, networks or domains. The scripts only use what the administrator created.

The policy the administrator attaches to your role allows exactly what the scripts do. Functions are scoped by name prefix; REST APIs, whose ids are not known in advance, by the tag `citta-custom-service` that the scripts put on everything they create.

{% code title="Developer policy (for your administrator)" overflow="wrap" %}

```json
{
  "Version": "2012-10-17",
  "Statement": [
    { "Sid": "WhoAmI", "Effect": "Allow", "Action": "sts:GetCallerIdentity", "Resource": "*" },
    { "Sid": "EcrLogin", "Effect": "Allow", "Action": "ecr:GetAuthorizationToken", "Resource": "*" },
    { "Sid": "EcrPush", "Effect": "Allow",
      "Action": ["ecr:DescribeRepositories", "ecr:DescribeImages", "ecr:BatchCheckLayerAvailability",
                 "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload", "ecr:PutImage",
                 "ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer", "ecr:BatchDeleteImage"],
      "Resource": "arn:aws:ecr:<region>:<account>:repository/<repository>" },
    { "Sid": "Functions", "Effect": "Allow",
      "Action": ["lambda:CreateFunction", "lambda:GetFunction", "lambda:GetFunctionConfiguration",
                 "lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration", "lambda:InvokeFunction",
                 "lambda:AddPermission", "lambda:RemovePermission", "lambda:TagResource", "lambda:DeleteFunction"],
      "Resource": "arn:aws:lambda:<region>:<account>:function:citta-cs-*" },
    { "Sid": "PassTheExecutionRole", "Effect": "Allow", "Action": "iam:PassRole",
      "Resource": "<LAMBDA_EXECUTION_ROLE_ARN>",
      "Condition": { "StringEquals": { "iam:PassedToService": "lambda.amazonaws.com" } } },
    { "Sid": "FunctionNetworkChecks", "Effect": "Allow",
      "Action": ["ec2:DescribeSecurityGroups", "ec2:DescribeSubnets", "ec2:DescribeVpcs"], "Resource": "*" },
    { "Sid": "ReadRestApis", "Effect": "Allow", "Action": "apigateway:GET",
      "Resource": ["arn:aws:apigateway:<region>::/restapis", "arn:aws:apigateway:<region>::/restapis/*"] },
    { "Sid": "CreateTaggedRestApis", "Effect": "Allow", "Action": "apigateway:POST",
      "Resource": "arn:aws:apigateway:<region>::/restapis",
      "Condition": { "Null": { "aws:RequestTag/citta-custom-service": "false" } } },
    { "Sid": "ChangeOnlyTaggedRestApis", "Effect": "Allow",
      "Action": ["apigateway:POST", "apigateway:PUT", "apigateway:PATCH", "apigateway:DELETE"],
      "Resource": "arn:aws:apigateway:<region>::/restapis/*",
      "Condition": { "Null": { "aws:ResourceTag/citta-custom-service": "false" } } },
    { "Sid": "ReadFunctionLogs", "Effect": "Allow",
      "Action": ["logs:DescribeLogGroups", "logs:FilterLogEvents", "logs:StartLiveTail"],
      "Resource": "arn:aws:logs:<region>:<account>:log-group:/aws/lambda/citta-cs-*" }
  ]
}
```

{% endcode %}

The function's own execution role holds no platform permission and reads no secret: the service authenticates to Citta with its key and receives its database credentials per request.

## Get the SDK

The SDK is the package [`citta-sdk`](https://pypi.org/project/citta-sdk/) on PyPI. It installs a `citta` command whose `citta new hello-world <directory>` writes this guide's starter project. The project's `requirements.txt` pins `citta-sdk` to the version that wrote it; your virtual environment and the image both install from that file, so they run the same SDK. To upgrade later, change the version there and reinstall.

## Pass 1: stand it up

{% stepper %}
{% step %}

### Create your project

Create a directory with a virtual environment, install the SDK, and let it write the starter project.

```bash
mkdir ~/hello-service && cd ~/hello-service
python3 -m venv .venv
.venv/bin/pip install 'citta-sdk[fastapi,aws]'
.venv/bin/citta new hello-world .
.venv/bin/pip install -r requirements.txt
cp citta.env.example citta.env
```

The last line `citta new` prints starts with `citta new: SUCCESS`. It writes nothing if a file it would create already exists.

Fill in `citta.env`: the values from your administrator, and a `SERVICE_CODE` of your own. The code is permanent and unique in the environment: 2 to 32 lower-case letters, digits and underscores, starting with a letter, for example `hello_jd`. Keep `VERSION_LABEL=dev`.

Then sign in to AWS with the profile your administrator gave you, and check it:

```bash
aws sso login --profile <your profile>        # if the profile uses IAM Identity Center
aws sts get-caller-identity --profile <your profile>
```

The second command prints the platform's account id. If it prints another, the profile is wrong.

The project now holds:

| Path                             | What it is                                                                                                  |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `app.py`                         | the service                                                                                                 |
| `lambda_handler.py`              | the Lambda entry point                                                                                      |
| `Dockerfile`, `requirements.txt` | the image; `requirements.txt` pins `citta-sdk`                                                              |
| `citta.env`                      | your values; not secret, but not for a repository either                                                    |
| `deploy/`                        | the scripts; each prints `SUCCESS` or `FAILED: <reason>` as its last line and keeps a log in `deploy/logs/` |
| `CLAUDE.md`                      | the rules for an AI coding assistant in this project                                                        |
| {% endstep %}                    |                                                                                                             |

{% step %}

### Build and push the image

```bash
deploy/10-image.sh --dry-run
deploy/10-image.sh
```

The dry run checks your profile, the region and the repository, and changes nothing. The real run builds the image for `linux/amd64`, the function's architecture, also on an Apple silicon laptop, and pushes it.

It worked when the last line reads `10-image: SUCCESS - pushed <repository>@sha256:...`. The function is always pinned to that digest, never to a tag.
{% endstep %}

{% step %}

### Create the function

```bash
deploy/20-function.sh --dry-run
deploy/20-function.sh
```

The function runs your image in the platform's subnets with the execution role your administrator created. A function in a network takes a minute or two to become active the first time.

The script then invokes the function once. The service has no key yet, so the SDK refuses every request with a named error, and that is the expected answer at this point:

```
ok   GET /healthz -> the SDK asks for its key (expected until you --set-key)
20-function: SUCCESS - citta-cs-hello-jd-dev runs <repository>@sha256:...
```

{% endstep %}

{% step %}

### Create the REST API

```bash
deploy/30-rest-api.sh --dry-run
deploy/30-rest-api.sh
```

This creates a regional REST API in front of the function and deploys a stage named after the version label. It ends with the three values you register in the next step:

```
Register the version in Citta with:
  Version      dev
  REST API id  a1b2c3d4e5
  Stage        dev
30-rest-api: SUCCESS - REST API id a1b2c3d4e5, stage dev
```

{% endstep %}

{% step %}

### Register the service and the version

In Citta, open the environment selector in the header and, under your account's name, choose **Account administration**. Then select **Custom Services**.

1. Select **Register service**, enter your `SERVICE_CODE` as the **Service code**, and select **Register**.
2. Open the service and select **Register version**. Enter the **Version**, **REST API id** and **Stage** that `30-rest-api.sh` printed, and select **Register**.

Registration checks that the REST API and the stage exist and maps the version's path on the environment's domain. If it refuses, the message names what is missing: a REST API or stage that does not exist in the platform's account and region, or a path that is already mapped to something else.

The dialog that opens shows the **Service key**. It is shown once. Create the file `citta.secrets.env` in your project with exactly one line, then select **I have stored the key**:

{% code title="citta.secrets.env" %}

```bash
CITTA_SERVICE_KEY=<the key from the dialog>
```

{% endcode %}

{% hint style="warning" %}
The key authenticates your service to the platform. It stays in `citta.secrets.env` and in the function's environment: never in the image, a repository, a ticket or a chat. If it leaks, open the version's actions and select **Rotate key**, then repeat the next step.
{% endhint %}
{% endstep %}

{% step %}

### Give the function its key

```bash
deploy/20-function.sh --set-key
deploy/30-rest-api.sh --smoke
```

The first copies the key into the function's environment without printing it, and checks that the function now answers `GET /healthz` with `200` and a request without a cookie with the SDK's `401`. The second checks `GET /healthz` through the REST API's stage. Neither can tell whether the key is the right one; the launch in the browser does that, at the end of pass 1:

```
20-function: SUCCESS - key set; GET /<session> now answers 401 without a cookie
30-rest-api: SUCCESS - REST API id a1b2c3d4e5, stage dev
```

{% endstep %}

{% step %}

### Pin an application to the version

The service is opened from an application, so an application has to pin it. In the workspace builder of your development environment:

1. If you have no application group to use, [create one](https://docs.citta.ai/application-groups-and-applications/application-groups/create-application-group), [add an object](https://docs.citta.ai/data-modeling-objects/create-object) with a field or two, and [add an application](https://docs.citta.ai/application-groups-and-applications/applications/managing-applications) that includes the object.
2. Open the application's details and its **Custom service** tab. Choose your service as the **Service** and `dev` as the **Version**, and select **Save**. Only active versions are listed.
3. Commit the change and deploy the application group, as for any other change ([versioning](https://docs.citta.ai/application-groups-and-applications/application-groups/versioning), [deployment](https://docs.citta.ai/application-groups-and-applications/applications/deployment)).

The deploy creates the application instance's database login and the schema your service owns. In the application list, the application now shows a **Custom Service** chip.
{% endstep %}

{% step %}

### Launch it

Open the application as a user would. In its left navigation, under **Custom Service**, select your service's code, then **Launch custom service**. From the workspace builder, the same is in the application's row actions under **Launch Options → Launch Custom Service**.

A new tab opens on your tenant's domain, under the version's path, and the service answers as the signed-in user. This answer is the proof that the key is right: the service could only read your name and the application's objects because the platform accepted its key.

```
hello jdoe from acme: 1 objects in agi_...: orders
```

Add `?object=<object name>` to the address to count that object's rows.

Then copy the new tab's address and check the route from the command line. The script sends the request without your cookie, so your service must answer with its own `401`:

```bash
deploy/40-check.sh 'https://acme.example.com/<version path>/<session>'
```

```
40-check: SUCCESS - the route reaches your function and the function holds a key
```

Pass 1 is done: the browser, the platform, API Gateway, your function and the application's data are connected.
{% endstep %}
{% endstepper %}

## Pass 2: make it yours

{% stepper %}
{% step %}

### Download the schema bundle

In the workspace builder, open the application's row actions and select **Launch Options → Download Schema Bundle**. The zip holds the application's three schemas as the platform deployed them, its seed rows, and a `context.local.json` describing you, your tenant and the application instance, as the platform would release them to your service. When the application links objects from another application group, the zip also holds that group's side of them, under its real schema name: what the application reaches, with empty tables. The owner's rows are not included; write the rows you need through your service.

Only users of the account that owns the service can download its bundle. The application has to be deployed and pinned to an active version.
{% endstep %}

{% step %}

### Load it into a local database

```bash
deploy/load-bundle.sh ~/Downloads/schema-bundle-<application>-<instance>.zip
```

This starts PostgreSQL 15 in Docker (container `citta-local-pg`, port `5433` on localhost, both set in `citta.env`) the first time, recreates the database `citta_local`, loads the bundle's SQL files in file order, and writes `context.local.json` into your project, pointed at that database. Loading a newer bundle later replaces the database.

```
load-bundle: SUCCESS - bundle loaded; start the service with CITTA_LOCAL_CONTEXT=$PWD/context.local.json
```

{% endstep %}

{% step %}

### Run the service locally

```bash
CITTA_LOCAL_CONTEXT=$PWD/context.local.json CITTA_LOCAL_FILES_DIR=$PWD/.citta-files \
  .venv/bin/uvicorn app:app --port 8000 --reload
```

In local mode every request is served as the user in `context.local.json`. No key, cookie or platform is involved, and the SDK logs one warning at startup so the mode cannot go unnoticed. Any session string works in the path:

```bash
curl http://localhost:8000/any-session
curl "http://localhost:8000/any-session?object=<object name>"
curl -X POST "http://localhost:8000/any-session?object=<object name>" \
  -H 'content-type: application/json' \
  -d '{"rows": [{"external_id": "local-1", "name": "written from my laptop"}]}'
```

Reads and writes go through the same views and merge routines as on the platform, against your local copy.
{% endstep %}

{% step %}

### Change the code

Edit `app.py`; with `--reload`, uvicorn restarts on every save. Add your service's own dependencies to `requirements.txt` and install them into `.venv` as well. [Build a service in Python](/developers/custom-services/build-a-service-python.md) covers the context, reading and writing objects, storing files and the session prefix.

Two rules carry over to every route you add: route under `/{session}/...`, and answer within 29 seconds, after which API Gateway gives up.
{% endstep %}

{% step %}

### Redeploy

```bash
deploy/redeploy.sh
```

This builds and pushes a new image and points the function at it. The REST API, the registration and the key do not change. Reload the service's tab in the browser; the first request after an update includes a cold start.
{% endstep %}
{% endstepper %}

## When something fails

Every script's last line names what failed, and its log in `deploy/logs/` holds the full output. The function's own log is in CloudWatch:

```bash
aws logs tail /aws/lambda/<function name> --since 15m --profile <your profile> --region <region>
```

| You see                                                        | Cause                                                                                 | Do                                                                                         |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `aws sts get-caller-identity failed`                           | not signed in to AWS, or the wrong profile                                            | `aws sso login --profile <your profile>`; check `AWS_PROFILE` in `citta.env`               |
| `ECR_REPOSITORY_URI is in account ..., your profile is in ...` | the profile is not the platform account's                                             | use the profile your administrator gave you                                                |
| `AccessDenied` in a script's log                               | your policy does not allow that action, or a name lacks the agreed prefix             | send the log line to your administrator                                                    |
| `exec format error` in the function's log                      | an image built for another architecture                                               | rebuild with `deploy/10-image.sh`, which builds `linux/amd64`                              |
| `ECR holds an image index`                                     | the build added attestations                                                          | `deploy/10-image.sh` builds without them; rebuild with it                                  |
| Registration refused: the REST API or stage does not exist     | wrong id or stage, or another account or region                                       | register exactly what `30-rest-api.sh` printed                                             |
| Registration refused: the path is already mapped               | another target already serves that version's path                                     | choose another version label, or ask your administrator                                    |
| The browser shows `401` with `"status": 401`                   | the request carried no platform session                                               | open the service from Citta, not from a bookmark of an old session                         |
| `502` or `Internal server error`                               | the function raised, most often because it has no key yet                             | `deploy/20-function.sh --set-key`; otherwise read the function's log                       |
| `503` with a `kind`                                            | the function's key is for another version, or a rotated key's grace window has closed | `deploy/20-function.sh --set-key` with this version's current key                          |
| `504` after about 29 seconds                                   | the function did not answer in time                                                   | make the work asynchronous; check the log for the slow call                                |
| `deploy/40-check.sh` reports a status other than `401`         | the request did not reach your function                                               | check that the version is active and registered with this function's REST API id and stage |

## Working with an AI coding assistant

The project is set up for an assistant to work in it safely.

* **`CLAUDE.md`** in the project root is read by Claude Code at the start of every session. It states what the assistant must never touch (the service key, your platform cookie, IAM), how to read a script's result, and the rules the platform imposes on the code. Keep it; add your own project rules to it.
* **Every script ends with one line**, `SUCCESS` or `FAILED: <reason>`, and offers `--dry-run` where it changes anything, so an assistant can show you a plan before acting and tell success from failure without guessing.
* **The developer documentation is machine-readable.** `https://docs.citta.ai/llms.txt` lists every published page; an assistant can fetch this guide and the pages it links to.

Two things stay with you: pasting the service key into `citta.secrets.env`, and anything done in the browser as the signed-in user. A good first instruction to the assistant:

```
Read CLAUDE.md and the Citta guide "Hello world on AWS" (find it through https://docs.citta.ai/llms.txt).
I have filled in citta.env. Run pass 1 up to the REST API, with a dry run before each step, and stop
when you have the values I must register in Citta.
```

## Clean up

Retire the version in Citta first (**Account administration → Custom Services**, the version's actions, **Retire version**). Retiring removes the version's path mapping, stops its key from working, and makes a deploy refuse any application still pinned to it. Then delete the AWS resources:

```bash
source citta.env
aws apigateway delete-rest-api --rest-api-id <REST API id> --profile "$AWS_PROFILE" --region "$AWS_REGION"
aws lambda delete-function --function-name <function name> --profile "$AWS_PROFILE" --region "$AWS_REGION"
```

The REST API id and the function name are in `.deploy-state` in your project.

## Where to go next

| To do this                                                         | Read                                                                               |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| Understand the request path and the security model                 | [Custom Services](/developers/custom-services/custom-services.md)                  |
| Read and write objects, store files, handle the session prefix     | [Build a service in Python](/developers/custom-services/build-a-service-python.md) |
| Look up each AWS setting the scripts apply                         | [Deploy to AWS](/developers/custom-services/deploy-to-aws.md)                      |
| Register and pin from a deployment pipeline instead of the screens | [Register and pin](/developers/custom-services/register-and-pin.md)                |
