# How to build and deploy an internal tool with AI, step by step

Build an internal tool with Claude Code or Codex, deploy it with team sign-in and Postgres, invite a teammate, add a daily job, and give an agent its own key. Tested end to end.

Canonical URL: https://getfloo.com/blog/how-to-build-and-deploy-an-internal-tool-with-ai

## TL;DR

**To build and deploy an internal tool with AI, start from a project that already has sign-in and a database, give your agent one specific prompt, and push. Here that is `floo projects create`, a seven-step prompt to Claude Code or Codex, and `git push`. The result is an on-call handoff log at a team-only URL, with Postgres, a daily digest job, an invited teammate, and a key another agent uses to file entries.**

- **Start from a working app, not an empty folder.** The starter is live before you write a line, so the agent only adds the feature.
- **Sign-in and roles come from the gateway.** The app reads `X-Floo-User-Role` and the agent writes no login code.
- **The schedule and the key live in config.** `[cron.morning-digest]` and a `[[routes]]` entry are reviewed like code.
- **Every output below is from a real run** on November 4, 2026, with the app renamed to `handoff-log`.

We ran this walkthrough end to end on a throwaway app before publishing it. The commands, the prompt, and the outputs are the ones from that run. Where something tripped us up, the step says so.

## What are you building?

An on-call handoff log. The person going off shift writes down what is still open, the next person reads it, and an admin marks items resolved. Every morning a job counts what is open and what got resolved, and an alerting agent can file entries on its own.

```text
browser -> floo gateway (team sign-in) -> Next.js app -> Postgres
alerting agent -> floo gateway (app API key) -> /api/handoffs
cron, 08:00 UTC -> scripts/digest.ts -> Postgres
```

It is small on purpose: two tables, one page, two API operations, one job. It exercises everything a real internal tool needs, and you can swap the domain for your own afterwards. The same steps deploy and host any app your agent builds; only the sign-in step is specific to tools for your own team. If you want an approval flow instead, the [approval queue template](https://getfloo.com/templates) is a finished version of that.

## Before you start

You need Node.js 22.12 or later, Claude Code or Codex, and the floo CLI signed in:

```bash
curl -fsSL https://getfloo.com/install.sh | bash
floo auth login
```

For Claude Code, `floo skills install --path .claude/skills/floo` gives the agent floo's own instructions. [Agent setup](https://getfloo.com/docs/guides/agent-setup) covers Codex, Cursor, and agents that use the HTTP API.

## Step 1: Create the project

```bash
floo projects create handoff-log
floo projects clone handoff-log
cd handoff-log
npm install
```

The first command returns in about ten seconds:

```text
✓ Project created.
app: handoff-log
url: https://handoff-log-dev.getfloo.app
login: hosted, invite-only
database: postgres
repo: https://github.com/floo-managed/floo-...git
follow first deploy: floo deploys watch --app handoff-log
next: floo projects clone handoff-log
```

floo hosts the repository, so you need no GitHub account. The project is a Next.js app with TypeScript, Drizzle, and Tailwind, and its first deploy is already running. That deploy also ran the starter's first migration, so your database already has a `users` table and a `notes` example table. Keep that in mind for step 2.

Open `AGENTS.md` in the clone. It tells the agent where pages, server actions, the JSON API, and the schema live, and how to read the signed-in person. The prompt below leans on it.

## Step 2: Build the tool with one prompt

Open Claude Code or Codex in the `handoff-log` folder and paste this. It is the exact prompt we tested.

```text
Build an on-call handoff log in this repo. Read AGENTS.md first and extend the starter; do not rebuild it.
1. Remove the notes example, then run `npm run db:generate`. In a second step, add a handoffs table (id, title, details, severity "low" or "high", status "open" or "resolved", created_by_id, created_by_name, created_at, resolved_by_name, resolved_at) and a digests table (id, open_count, high_count, resolved_last_day, created_at) to db/schema.ts and run `npm run db:generate` again. Two steps keep drizzle-kit from asking whether a table was renamed. Never edit drizzle/0000_initial.sql.
2. The page at / lists open handoffs, newest first, with a form to add one, and shows the latest digest at the top. Only people with role "admin" see a Resolve button, and the server action checks the role again.
3. Under /api/handoffs, GET returns open handoffs and POST adds one, with the creator from callerFrom(). Validate with zod in lib/handoffs.ts and document both operations in openapi.ts and operations.ts.
4. Write scripts/digest.ts. It counts open handoffs, open high-severity handoffs, and handoffs resolved in the last 24 hours, and inserts one digests row. Use the postgres package and DATABASE_URL directly so it runs on its own, and copy it into the Dockerfile's runner stage next to scripts/migrate.ts.
5. In floo.app.toml, add [cron.morning-digest] with schedule = "0 8 * * *", command = "node_modules/.bin/tsx scripts/digest.ts", service = "web", and timeout = 60.
6. Run `npm install`, `npm run build`, `npm test`, and `floo preflight --json`, and fix every error.
7. Commit with a short message. Do not push.
```

Three lines in that prompt are there because we would have hit a problem without them:

- **Two migrations, not one.** Dropping `notes` and creating `handoffs` in a single `drizzle-kit generate` makes it ask, interactively, whether `handoffs` is `notes` renamed. An agent cannot answer that prompt. Doing it in two steps produces `0001` (drop) and `0002` (create) with no question.
- **Never edit `0000_initial.sql`.** The starter's deploy already applied it. The migration runner refuses a changed applied migration, so the deploy would stop.
- **Copy the job's script into the image.** The starter's Dockerfile copies only the built app and `scripts/migrate.ts` into the final stage. A cron script left out of the image fails at run time, not at deploy time.

The role check runs twice on purpose. Hiding the button is presentation. The server action reads the role from the gateway again and refuses anyone who is not an admin:

```ts
// app/actions.ts (generated)
export async function resolve(formData: FormData): Promise<void> {
  const user = await getIdentity();
  if (user.role !== "admin") throw new Error("Only admins can resolve handoffs.");
  await resolveHandoff(user, formData.get("id"));
  revalidatePath("/");
}
```

In our run, Codex touched 27 files (13 changed, 11 added, 3 removed): the page and action, `lib/handoffs.ts`, `app/api/handoffs/route.ts` and its OpenAPI contract, two migrations, `scripts/digest.ts`, one line in the Dockerfile, the cron block, and tests. `npm run build`, 56 tests, and `floo preflight` passed. The cron block it wrote:

```toml
[cron.morning-digest]
schedule = "0 8 * * *"
command = "node_modules/.bin/tsx scripts/digest.ts"
service = "web"
timeout = 60
```

Read the diff before you push. It is your app now.

One thing about Codex specifically: in its `workspace-write` sandbox, `.git` is read-only, so the final commit failed and Codex said so. If that happens, commit yourself with `git add -A && git commit -m "Add on-call handoff log"`.

## Step 3: Deploy it

```bash
git push
floo deploys watch --app handoff-log --commit $(git rev-parse HEAD)
```

Pass `--commit`. Without it, `watch` can attach to the starter's deploy, which is already live, and report success before your code has built. To check from a script, filter the deploy list by the same commit:

```bash
floo deploys list --app handoff-log --json \
  | jq -r --arg sha "$(git rev-parse HEAD)" '.data.deploys[] | select(.commit_sha == $sha) | .status'
```

Our push went from `building` to `deploying` to `live` in 47 seconds:

```text
✓ Deploy live: https://handoff-log-dev.getfloo.app
```

The deploy ran the two new migrations before moving traffic. Had one failed, the deploy would have stopped and the starter would have kept serving.

Now open the URL in a private window. You get floo's sign-in page, not the app. From a terminal:

```bash
curl https://handoff-log-dev.getfloo.app/
# {"error": "authentication_required", "login_url": "https://handoff-log-dev.getfloo.app/__floo/login?..."}
```

Nobody reaches your code without an invitation. You, as the project's creator, are already a member with the `admin` role.

## Step 4: Invite a teammate

```bash
floo apps invite teammate@example.com --role member --app handoff-log
```

```text
✓ Invited teammate@example.com to 'handoff-log'.
```

They get an email with a one-time link and sign in with a floo account for that address. To confirm the email went out, ask for the pending invites as JSON:

```bash
floo apps invites --app handoff-log --json
# ... "email": "teammate@example.com", "role": "member", "status": "pending", "delivery_status": "sent", "expires_at": "2026-11-11T18:25:45.590006Z" ...
```

A `member` can add handoffs. Invite the on-call lead with `--role admin` so they can resolve them. App users do not use builder seats and get no access to floo itself. `floo apps members --app handoff-log` lists accepted members, and `floo apps member-remove` ends someone's sessions immediately.

## Step 5: Run the scheduled job

The `[cron.morning-digest]` block shipped with the deploy, so the job already exists:

```bash
floo cron list --app handoff-log
```

```text
┌────────────────┬───────────┬─────────┬─────────┬─────────────┬──────────┐
│ Name           ┆ Schedule  ┆ Service ┆ Enabled ┆ Last Status ┆ Last Run │
╞════════════════╪═══════════╪═════════╪═════════╪═════════════╪══════════╡
│ morning-digest ┆ 0 8 * * * ┆ web     ┆ yes     ┆ -           ┆ never    │
└────────────────┴───────────┴─────────┴─────────┴─────────────┴──────────┘
```

Run it once instead of waiting for 08:00 UTC:

```bash
floo cron run morning-digest --app handoff-log
```

```text
✓ Cron job queued; execution has not been confirmed.
Check the result with `floo cron show morning-digest --env dev`.
```

"Queued" is not "succeeded". Check again with `floo cron list`:

```text
│ morning-digest ┆ 0 8 * * * ┆ web     ┆ yes     ┆ success     ┆ 2026-11-04T18:26:06.061693Z │
```

The job runs in the web service's image with the same `DATABASE_URL`, which is why `scripts/digest.ts` needed nothing but the `postgres` package. Schedules are UTC and there is no retry, so a missed run waits for the next one. This job inserts a fresh row each time and the page shows the newest, so running it twice is harmless. Output from a run is in `floo logs query --app handoff-log --cron morning-digest --env dev`.

## Step 6: Let an agent file entries with its own key

The starter already routes `/api` to app API keys holding the `api` scope, while the page stays behind sign-in:

```toml
[[routes]]
path = "/"
access = "accounts"
service = "web"

[[routes]]
path = "/api"
access = "api_key"
scope = "api"
service = "web"
```

Create a consumer for the alerting agent and a key for it:

```bash
floo apps consumers create oncall-agent --app handoff-log
floo apps keys create oncall-agent-key --consumer oncall-agent --scope api --app handoff-log
```

The raw key prints once on stdout; the ID, prefix, and scopes go to stderr. Put it straight into the agent's secret store as `HANDOFF_KEY` and keep it out of prompts, logs, and the repository. Then the agent can file and read entries:

```bash
curl -X POST https://handoff-log-dev.getfloo.app/api/handoffs \
  -H "Authorization: Bearer $HANDOFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Checkout p95 latency alert","details":"Fired twice overnight, cleared on its own. Watch after the 9am deploy.","severity":"high"}'

curl -H "Authorization: Bearer $HANDOFF_KEY" https://handoff-log-dev.getfloo.app/api/handoffs
```

What we got back:

| Request | Status |
|---|---|
| POST `/api/handoffs` with the key | 201, entry created by `oncall-agent` |
| GET `/api/handoffs` with the key | 200, the open entry |
| GET `/api/handoffs` with no key | 401 |
| GET `/api/handoffs` with a made-up key | 401 |
| GET `/` with the key | 401, a key does not sign anyone in to the page |

The gateway strips the key before the request reaches the app and passes the consumer's identity instead, so the entry is attributed to `oncall-agent`, not to whoever made the key. After the next digest run, the page read "1 open, 1 high severity, 0 resolved in the preceding 24 hours". The agent also gets the API contract from `/api/openapi.json`, so it does not need these docs. [How to give your agent its own key](https://getfloo.com/blog/give-your-agent-its-own-api-key-to-an-internal-app) covers scopes, rotation, and revocation.

## How do I try it on my laptop?

The gateway is not in front of `localhost`, so `floo dev` adds the same identity headers with a local proxy and connects to the dev database. The starter does not declare a local command yet, so add one under `[services.web]` first:

```toml
dev_command = "npm run dev"
```

```bash
floo dev --app handoff-log --fixture-user lead@example.com --fixture-role admin
```

It runs the migrations, starts Next.js, and prints an auth-proxied URL. Open that one, not port 3000, and you see the page as an admin, Resolve buttons included. Drop `--fixture-role admin` to see it as a member. The first `next dev` run appends a block to `AGENTS.md`; commit it with your work.

## Shipping it to the team

Every push deploys to dev, and dev has its own database. A tool a few people use can stay there. When you want a stable version, promote it, and prod gets its own database and its own copy of the cron job:

```bash
floo releases promote --app handoff-log
floo cron list --app handoff-log --env prod
```

If your code lives in your own GitHub repository instead, the same app works with `floo apps github connect owner/repo`; the [quickstart](https://getfloo.com/docs/introduction) walks through it. For a repo already on your laptop that did not start from the starter, `floo deploy` creates the project and pushes in one command, as [deploy without GitHub](https://getfloo.com/docs/deploy/without-github) shows.

## What made this reproducible

- **A live starter.** The agent extended a running app with sign-in, a database, a migration runner, and an API contract already in place. It did not choose a stack, an auth library, or a host.
- **Specific instructions where the defaults bite.** Two migrations, a frozen first migration, and the Dockerfile line were the difference between one push and three.
- **Checks the agent can run.** `npm test` and `floo preflight --json` caught errors before the push instead of after.
- **Verification by commit.** Matching the deploy to `git rev-parse HEAD` is the only way to know your code, not the starter, is live.

floo deploys and hosts apps built with AI, whether they are tools for your team like this one or apps for your customers. More apps to start from are on the [templates](https://getfloo.com/templates) page. [Internal apps](https://getfloo.com/internal-apps) explains how floo compares with other ways to host them, and the [backend overview](https://getfloo.com/backend) lists each part floo supplies. If you are still choosing where to run tools like this, read [the best platform to host internal tools built with AI](https://getfloo.com/blog/best-platform-to-host-internal-tools-built-with-ai).

## Sources

- floo docs: [projects](https://getfloo.com/docs/cli/projects), [host an internal tool](https://getfloo.com/docs/deploy/internal-tool), [deploy without GitHub](https://getfloo.com/docs/deploy/without-github), [deploys](https://getfloo.com/docs/cli/deploys), [app sign-in](https://getfloo.com/docs/guides/app-auth), [Postgres](https://getfloo.com/docs/guides/postgres), [cron jobs](https://getfloo.com/docs/guides/cron-jobs), [cron CLI](https://getfloo.com/docs/cli/cron), [app API keys](https://getfloo.com/docs/guides/app-api-keys), [local dev](https://getfloo.com/docs/cli/dev), [environments](https://getfloo.com/docs/guides/environments), [agent setup](https://getfloo.com/docs/guides/agent-setup)

## FAQ

### Can I build an internal tool with Claude Code or Codex and deploy it the same day?

Yes. Create a floo-hosted project with floo projects create, clone it, and paste one prompt into Claude Code or Codex. The agent extends a Next.js starter that already has team sign-in and Postgres, and a git push deploys it. In our run the deploy went live 47 seconds after the push.

### How do I invite a teammate to an internal tool?

Run floo apps invite teammate@example.com --role member --app <name>. They get a one-time link, sign in with a floo account for that email, and the app sees them as X-Floo-User-Role: member. App users do not use builder seats and get no access to floo itself.

### How do I add a scheduled job to an app my agent built?

Add a [cron.<name>] section to floo.app.toml with a schedule in UTC, a command, and the service whose image runs it. The next deploy creates the job. floo cron run <name> triggers it once, and floo cron list shows its last status. Jobs do not retry, so make the command safe to run twice.

### How does another agent call the internal tool without signing in?

Create a consumer and an app API key with floo apps consumers create and floo apps keys create --scope api. The key works only on routes declared with access = "api_key" and that scope, the gateway strips it before your code sees the request, and a missing or wrong key gets 401.

### Do I need a GitHub account?

No. floo projects create hosts the repository for you, and git push to it deploys. If you would rather keep the code in your own GitHub repository, connect it with floo apps github connect and every push to the default branch deploys the same way.
