---
name: talkable-invite-program
description: >-
  Adds a Talkable invite program to the product of the current repo. Signed-in users get invite links and codes,
  and signup checks each invite. Use it when the user asks for a Talkable invite program or invite-only signup.
---

# Talkable invite-only campaign: instructions for coding agents

Make a Talkable invite-only campaign for the product in the current repo. You need no Talkable account, no MCP
setup and no API key from the owner. You change only the code of the product. Our AI assistant makes the Talkable
part (step 2). The owner claims the sandbox at the end (step 6).

If the agent instructions of the repo have a "Talkable invite program" section for this product, an earlier
session started the campaign. Then start with "Change the campaign later", at the end.

- API: `https://www.talkable.com/api/v2/agent`
- Campaign tools: the `mcp_url` that step 1 saves

Set `TALKABLE_CLIENT` to your own name in lowercase kebab-case, for example `claude-code`, `cursor` or `codex`.
Send `X-Talkable-Client: $TALKABLE_CLIENT` on each call to the API and to the campaign tools. API answers are
JSON: `{"ok": true, "result": ...}` or `{"ok": false, "error_code": "...", "error_message": "..."}`. On
`rate_limited`, wait `Retry-After` seconds, then try again.

## Before you start

These three steps save or send data outside the repo:

- Step 1 creates a sandbox on Talkable, and saves its two keys in `~/.config/talkable/sandboxes/`.
- Step 2 sends a brief to our AI assistant: the name, the URLs, the brand and the landing copy of the product.
- Step 6 sends the email of the owner to Talkable.

Some tools do not ask the owner before each command. Instead, a safety check stops the risky commands, for example
in the auto mode of Claude Code. That check can stop these steps, also after the owner says yes in the chat.

If your tool works in such a mode, tell the owner these three steps before step 1. Ask the owner to switch to the
mode that asks before each command, for example with Shift+Tab in Claude Code. Then wait for the answer.

A tool can also run each command with no question and no safety check, for example in the bypass permissions mode
of Claude Code. If your tool works in that mode, do not ask the owner to switch. Do not stop before the report of
step 5, unless you cannot continue without the owner.

If a safety check stops a command of these instructions, stop all the steps, also the code changes. Do not do that
step or that command in another way, for example with a different tool. Tell the owner which command it stopped,
and why. Then wait for the answer.

## 1. Create a sandbox

One call, with no input. Make one sandbox for each environment of the product and Talkable API origin, and use it
again. Find the environments in the repo: its env files, its deploy config and its CI.

Development and production always get a sandbox. Staging gets one only if the repo has a staging environment. If you
are not sure, ask in the report (step 5). Do steps 1 to 8 for development first. Then do section 9 for the other
environments.

`TALKABLE_ENV` names the environment: `development` (the default), `staging` or `production`. For staging or
production, put the line `TALKABLE_ENV=staging` or `TALKABLE_ENV=production` at the top of the block below. Put the
same line at the top of each later command that starts with that block. For development, if your shell keeps
variables, put the line `TALKABLE_ENV=development` at the top of the block.

The canonical root directory of the product is its identity, not the name of the current directory.

In a monorepo, set `PRODUCT_ROOT` to the directory of this product. If you do not, the commands use the Git root.
Use the same root from the frontend and backend directories. The commands below use `curl`, `git`, `jq` and
`shasum`.

```bash
set -e
API_ORIGIN='https://www.talkable.com'
TALKABLE_CLIENT='AGENT_NAME'
TALKABLE_ENV="${TALKABLE_ENV:-development}"
case "$TALKABLE_ENV" in
  development|staging|production) ;;
  *) echo 'Set TALKABLE_ENV to development, staging or production.' >&2; exit 1 ;;
esac
PRODUCT_ROOT="$(cd "${PRODUCT_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd -P)}" && pwd -P)"
# Development keeps the ID from before environments, so its saved file still loads.
SANDBOX_ID="$({ printf '%s\0%s' "$API_ORIGIN" "$PRODUCT_ROOT"
  [ "$TALKABLE_ENV" = development ] || printf '\0%s' "$TALKABLE_ENV"; } | shasum -a 256 | cut -d ' ' -f 1)"
SANDBOX_FILE="$HOME/.config/talkable/sandboxes/$SANDBOX_ID.json"
# Staging and production claims send the saved key of development (step 6).
DEVELOPMENT_FILE="$HOME/.config/talkable/sandboxes/$(printf '%s\0%s' "$API_ORIGIN" "$PRODUCT_ROOT" | shasum -a 256 | cut -d ' ' -f 1).json"

if [ -e "$SANDBOX_FILE" ]; then
  jq -e --arg product_root "$PRODUCT_ROOT" --arg api_origin "$API_ORIGIN" --arg environment "$TALKABLE_ENV" \
    --arg mcp_url 'https://www.talkable.com/mcp' \
    '.product_root == $product_root and .api_origin == $api_origin and (.environment // "development") == $environment
      and .ok == true and (.result.api_key | type == "string" and length > 0) and .result.mcp_url == $mcp_url' \
    "$SANDBOX_FILE" > /dev/null || { echo 'Saved credentials do not match this product, environment and API origin.' >&2; exit 1; }
elif [ -n "${NO_NEW_SANDBOX:-}" ]; then
  echo 'No saved key for this product.' >&2; exit 1
elif [ "$TALKABLE_ENV" != development ] &&
    ! jq -e '.result.api_key | type == "string" and length > 0' "$DEVELOPMENT_FILE" > /dev/null 2>&1; then
  echo 'No saved development key for this product.' >&2; exit 1
else
  (umask 077; set -C; set -o pipefail; mkdir -p "$HOME/.config/talkable/sandboxes"
    exec 3> "$SANDBOX_FILE" || exit 1
    curl -sS -X POST https://www.talkable.com/api/v2/agent/sandboxes -H "X-Talkable-Client: $TALKABLE_CLIENT" | \
      jq -e --arg product_root "$PRODUCT_ROOT" --arg api_origin "$API_ORIGIN" --arg environment "$TALKABLE_ENV" \
        'if .ok == true then . + {product_root: $product_root, api_origin: $api_origin, environment: $environment}
         else error("Sandbox creation failed: \(.error_code)") end' >&3 || { rm -f "$SANDBOX_FILE"; exit 1; })
fi
API_KEY="$(jq -r '.result.api_key' "$SANDBOX_FILE")"
MCP_URL="$(jq -r '.result.mcp_url' "$SANDBOX_FILE")"
SITE_ID="$(jq -r '.result.site_slug' "$SANDBOX_FILE")"
mcp() {
  curl -s "$MCP_URL" -X POST -H "Authorization: Bearer $API_KEY" -H "X-Talkable-Client: $TALKABLE_CLIENT" \
    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$1"
}
```

If your shell does not keep variables between commands, start each later command with this block. When the file
exists, the block only examines it. It sets `TALKABLE_CLIENT`, `TALKABLE_ENV`, `SANDBOX_FILE`, `API_KEY`, `MCP_URL`
and `SITE_ID` again, and defines `mcp`, the function that calls the campaign tools.

`set -C` stops a concurrent create before it overwrites a saved owner key, and a failed create removes the file
that it made. A context mismatch is not an expired sandbox: stop, and do not delete or overwrite that file. Do
not use old basename-only files automatically. If the campaign tools answer `Unauthorized`, make sure that the
context above is correct. Then run the claim block of step 6 one time, because the owner can press the button of
the link after the end of an earlier session. If it prints `claimed`, `moved` or `unauthorized`, do what step 6
says for that state.

`result` has `api_key`, `site_slug`, `private_api_key` and `mcp_url`. `product_root`, `api_origin` and `environment`
record the context. `SITE_ID` is the Site ID, `site_slug`: run `echo "$SITE_ID"` to see it.

The two keys, `api_key` and `private_api_key`, are secrets. Keep them in that file, which only the owner's user can
read. Never put a key in `/tmp`, in a file that git tracks, in logs or in the chat. Do not enable shell tracing.

## 2. Ask our assistant to build the campaign

The campaign tools are plain JSON-RPC over HTTP. The `mcp` function of step 1 calls them. The answer of a tool is
in `result.content[0].text`. `result.isError: true` means that the tool refused the call or failed: read the text.
In the answer of `list_campaigns`, a second text item, if there is one, is a note: do what it says. For example,
when the owner changes the Site ID, a note gives the new Site ID.

Our AI assistant builds the campaign in the sandbox: the brand of the site, one invite-only campaign, its copy and
the page where friends land. Use only the three calls of this step, and do not call the other campaign tools
yourself. Write a brief from the code of the product, with these lines:

- `Product name`: the name of the product. For development and staging, add the environment in parentheses, for
  example `Acme Notes (staging)`. The block shows this name, so a test block names its environment. For production,
  give the plain name.
- `Site URL`: the full URL of the product in this environment, for example `http://localhost:3000` for development.
- `Signup page URL`: the full URL of the signup page in this environment. After the claim, an invite link opens
  this page, with the advocate's code in `invite`.
- `Brand`: the colors and fonts of the CSS variables or theme tokens of the repo, with their values. Give also the
  color of the main buttons, the color of their text and their corner radius. Give each size in `px`, not in `rem`.
- `Share image`: the full `https://` URL of the image that the public pages of the product give in `og:image`. If
  they give none, use the URL of a PNG or JPG of its logo, not an SVG. Use the URL on the production site for each
  environment. A link preview of an invite shows this image.
- `Tone`: the heading and two or three lines of the landing page, word for word. Write them all on the `Tone`
  line, each in double quotes.
- `Invites per user`: how many friends each user can invite, 3 if the owner did not give a number.
- `Layout`: `stacked` only if the owner wants the block in one column on wide pages too (step 3).

Leave out a line if the repo does not show its value. Never put keys, tokens, passwords, the content of `.env`
files or customer data into the brief: an LLM reads it. Keep the brief under 10,000 characters. Before the claim,
the sandbox gets 10 assistant runs, so put all that you know into one brief.
Each retry and each fix is one more run.

Send the brief with this block. Put your values in place of the words in capitals:

```bash
brief() {
  cat <<'BRIEF'
Build the invite-only campaign of this product.
Product name: PRODUCT_NAME
Site URL: SITE_URL
Signup page URL: SIGNUP_URL
Brand: BRAND
Share image: SHARE_IMAGE_URL
Tone: TONE
Invites per user: 3
BRIEF
}
BRIEF="$(brief)"
ANSWER="$(mcp "$(jq -nc --arg site_slug "$SITE_ID" --arg prompt "$BRIEF" '{jsonrpc: "2.0", id: 3, method: "tools/call",
  params: {name: "prompt_ai_assistant", arguments: {site_slug: $site_slug, prompt: $prompt}}}')")" || true
printf '%s' "$ANSWER" | jq -er '.result | select(.isError == false) | .content[0].text |
  capture("session_id (?<id>[0-9]+) to get the answer") | "Session ID: \(.id)"' 2> /dev/null || {
  printf '%s' "$ANSWER" | jq -er '.result.content[0].text // .error.message // empty' >&2 2> /dev/null ||
    echo 'The campaign tools gave no answer.' >&2
  exit 1
}
```

The block prints the session ID of the run. If the assistant refuses the run, the block prints the reason. Act
on it:

- "This sandbox used its 10 assistant runs. The owner gets more runs after the claim.": stop, and tell the
  owner that the claim gives more runs.
- A text that ends with "Try again in a minute.", for example
  "Too many sandbox runs are active now. Try again in a minute.", or "Too many concurrent sessions. Please wait for one to finish.":
  wait one minute, then run the block again.
- "Sandboxes used all their assistant runs for today. Try again tomorrow.": stop, and tell the owner to try again
  tomorrow.
- "Agent sandboxes are paused. Try again later.": stop, and tell the owner.
- Any other text: stop, and tell the owner.

Do step 3 now, while the run works. Then wait for the end of the run with this block. Put the session ID in place
of `SESSION_ID`:

```bash
SECONDS=0
while :; do
  ANSWER="$(mcp "$(jq -nc --arg site_slug "$SITE_ID" '{jsonrpc: "2.0", id: 4, method: "tools/call",
    params: {name: "check_ai_assistant_run", arguments: {site_slug: $site_slug, session_id: SESSION_ID}}}')")" || true
  STATE="$(printf '%s' "$ANSWER" | jq -er '.result | (.content[0].text // "") as $text |
    if .isError == false and ($text | startswith("The assistant is still working")) then "working"
    elif .isError == false and ($text | contains("\n\n---\nContinue in the AI Assistant: ")) then "finished"
    elif .isError == false or ($text | startswith("The assistant run ")) then "failed"
    else "error" end' 2> /dev/null)" || STATE=error
  if [ "$STATE" != working ] || [ "$SECONDS" -ge 75 ]; then break; fi
  sleep 10
done
echo "Run: $STATE"
printf '%s' "$ANSWER" | jq -r '.result.content[0].text // .error.message // empty' 2> /dev/null
```

The block waits 10 seconds between calls. One run of the block ends in less than 2 minutes, because the shell of an
agent can stop a longer command. If your shell stops
it, run it again. It prints `Run:` and the state of the run, then the answer:

- `finished`: the run ended. The answer tells what the assistant set, and what it did not set and why.
- `failed`: the run failed. Send the same brief once more. If that run fails too, stop, and tell the owner.
- `error`: the check failed, not the run. Read the text, then run this block again. If it fails again, stop, and
  tell the owner.
- `working`: the run continues. Run this block again. If the run continues 15 minutes after you sent the brief,
  stop, and tell the owner.

Then get the campaign with this call. Take the `id` of the campaign whose `normalized_preset_name` is
`invite_only`: it is `CAMPAIGN_ID` in the steps below. Do not take the ID from the answer of the run, because a
model can write a wrong number. If the list has no invite-only campaign, the run failed: act as for `failed` above.

```bash
mcp "$(jq -nc --arg site_slug "$SITE_ID" '{jsonrpc: "2.0", id: 2, method: "tools/call",
  params: {name: "list_campaigns", arguments: {site_slug: $site_slug}}}')"
```

For a fix later, send a new brief with the first block of this step. Put the fix in place of all the lines
between `cat <<'BRIEF'` and the next `BRIEF` line, and do not send the build lines again. Name the campaign ID and only the change,
for example a new site URL that the owner gives you. With a site URL, send also the full URL of the signup page
on it. The assistant sets the page where friends land only from that URL. Then wait for the run again.

A fix run can break the invite links of the block. So if you sent a fix, open one invite link of the block after
the claim and before the launch. Make sure that it leads to the signup page, with `invite=` and a code in the URL.

Until the owner claims the sandbox, it stays in test mode: it cannot launch, send emails or add users.

## 3. Add the invite block and the signup check

Do this step while the run of step 2 works.

The block invites people to the product itself: each signed-in user gets a personal link that brings friends in
as new users. The product's own invites are a different thing. "Invite teammate", "Add member", "Share project"
and similar flows add a person to an organization, team, workspace, project or site, with access to the
inviter's data. Leave each of those flows as it is (its pages, dialogs, buttons, links, emails and signup), and
keep the block off its pages.

Put the block on 2-3 pages that the product has already, where signed-in users look at their own account. Put
one block on each page. Use the dashboard or home page that users see after they sign in, and their profile or
account page. Add one more page that fits, for example a welcome page after signup. Use only pages that each
signed-in user can open. A receipt or an order page needs a purchase first, so do not use it. Add no page for the
block, such as `/share` or `/invite`, and no navigation link. Make the block one component or partial, and use it
on each of those pages.

A page can show only one block at a time. When the script loads, it puts the block into the first `talkable-offer`
div of the page and removes the other ones. So do not put the block in a dialog, a modal or a drawer, for example
account settings that open over the home page. If the product has no account page, use only the other pages.

The block is as wide as its `div`. Put the `div` in the main content column of the page, not in a sidebar or a
side column. Give it the full width of that column. When the `div` is 800px wide or more, the block shows two
columns. Below that, it shows one column.

Keep the Site ID in the env var `TALKABLE_SITE_ID`, because each environment has its own Site ID. The script URL of
the block and the signup check both read it. Set it to the value of `SITE_ID` now. The claim can change the Site ID
(step 6).

The backend of the product must sign the email of the signed-in user with the private API key of the site,
`TALKABLE_PRIVATE_API_KEY`. Show the block only to a signed-in user, and only when `TALKABLE_PRIVATE_API_KEY` has
a value. Talkable closes a block that has no valid signature. In development, log a warning in the backend when the
key has no value. Without this warning, a key with no value hides the block and gives no error.

Do this only for development. For staging and production, the owner puts the key into the secrets of that
environment (section 9). Step 1 saved the private API key in `SANDBOX_FILE`. Put it into the env file that has the
other development secrets of the backend, for example the URL of its database. In a monorepo, that file can be in
the directory of the app, not at the root. Set `ENV_FILE` to the full path of that file, then run this:

```bash
[ "$TALKABLE_ENV" = development ] || { echo 'Put only the development key into a local env file.' >&2; exit 1; }
ENV_FILE="${ENV_FILE:-$PRODUCT_ROOT/.env.local}"
case "$ENV_FILE" in /*) ;; *) echo 'Set ENV_FILE to a full path.' >&2; exit 1 ;; esac
[ ! -L "$ENV_FILE" ] || { echo "$ENV_FILE is a symlink. Set ENV_FILE to the file that it points to." >&2; exit 1; }
git -C "${ENV_FILE%/*}" check-ignore -q "$ENV_FILE" || { echo "Git does not ignore $ENV_FILE." >&2; exit 1; }
DIR="${ENV_FILE%/*}" LAST=
while REPO="$(git -C "$DIR" rev-parse --show-toplevel 2> /dev/null)" && [ "$REPO" != "$LAST" ]; do
  ! git -C "$REPO" ls-files --error-unmatch -- "$ENV_FILE" > /dev/null 2>&1 || { echo "The git repo $REPO tracks $ENV_FILE." >&2; exit 1; }
  LAST="$REPO" DIR="${REPO%/*}/"
done
KEY_LINE="TALKABLE_PRIVATE_API_KEY=$(jq -er '.result.private_api_key' "$SANDBOX_FILE")" || exit 1
if ! grep -qxF "$KEY_LINE" "$ENV_FILE" 2> /dev/null; then
  if grep -q '^TALKABLE_PRIVATE_API_KEY=' "$ENV_FILE" 2> /dev/null; then
    echo "$ENV_FILE has another TALKABLE_PRIVATE_API_KEY. Ask the owner which key to keep." >&2
    exit 1
  fi
  (umask 077; printf '\n%s\n' "$KEY_LINE" >> "$ENV_FILE")
fi
unset KEY_LINE
```

The commands write `TALKABLE_PRIVATE_API_KEY` into `ENV_FILE` only if git ignores that file and no git repo around
it tracks it. They never print the key. If the file is in no git repo, the commands refuse it. Then ask the owner to
name a file that git ignores, or to add the key to the file as `TALKABLE_PRIVATE_API_KEY`. The key is
`private_api_key` in `SANDBOX_FILE`.

If git does not ignore the file, use another env file, or add the file to `.gitignore`. If a git repo around the
file tracks it, use another env file. Or ask the owner to remove the file from that repo with `git rm --cached`.
Then run the commands again. Read the key only in the backend. Never print it or the env file.

Some tools that start the dev server give it only the env vars that their config names. For example, Turborepo
gives a task a variable of the root env file only if `turbo.json` names it or has `*`. In such a config, add
`TALKABLE_SITE_ID` and `TALKABLE_PRIVATE_API_KEY` where it names the other env vars of the backend. Then restart
the dev server. If the backend logs the warning above, it did not get the key.

```html
<div id="talkable-offer"></div>
<script>
  window._talkableq = window._talkableq || [];
  window._talkableq.push(['authenticate_customer',
    {email: 'USER_EMAIL', signed_at: 'SIGNED_AT', signature: 'SIGNATURE'}]);
  window._talkableq.push(['register_affiliate', {campaign_tags: 'invite-only'}]);
</script>
<script async src="https://d2jjzw81hqbuqv.cloudfront.net/integration/clients/TALKABLE_SITE_ID.min.js"></script>
```

Put the value of `TALKABLE_SITE_ID` in the script URL. Escape the three values for JavaScript the way the
templates of the product escape values. The backend makes them each time that it shows the page:

- `USER_EMAIL`: the email of the signed-in user in lowercase, as one plain address, with no name and no spaces.
  Never sign an email that a request gives.
- `SIGNED_AT`: the current Unix time in whole seconds. Talkable accepts it from 1 day old to 5 minutes ahead.
- `SIGNATURE`: HMAC-SHA256 with `TALKABLE_PRIVATE_API_KEY` as the key, over `invite-only:v1:USER_EMAIL:SIGNED_AT`,
  in lowercase hex. Use the email exactly as the snippet sends it.

In Node:

```js
const crypto = require('crypto');

function talkableIdentity(email) {
  email = email.toLowerCase();
  const signedAt = String(Math.floor(Date.now() / 1000));
  const signature = crypto.createHmac('sha256', process.env.TALKABLE_PRIVATE_API_KEY)
    .update(`invite-only:v1:${email}:${signedAt}`).digest('hex');
  return { email, signed_at: signedAt, signature };
}
```

In Python:

```python
import hashlib, hmac, os, time

def talkable_identity(email):
    email = email.lower()
    signed_at = str(int(time.time()))
    message = f'invite-only:v1:{email}:{signed_at}'.encode()
    signature = hmac.new(os.environ['TALKABLE_PRIVATE_API_KEY'].encode(), message, hashlib.sha256).hexdigest()
    return {'email': email, 'signed_at': signed_at, 'signature': signature}
```

In a single-page app, add a small endpoint to the backend that only a signed-in user can call. It returns
`{site_id, email, signed_at, signature}` for that user, with `site_id` from `TALKABLE_SITE_ID`. The page gets
these values from the endpoint, pushes the two calls with them, and then loads the script with that `site_id`.

`register_affiliate` loads the campaign into the `div` on any page. Keep `invite-only` as written. Until launch,
the campaign's tags read `test-invite-only`. Do not use that tag: it shows the test campaign to every user. The
block brings its own heading and stays empty until launch, so put the bare `div` in the page.

The signup check: a friend's invite link opens the signup page with the code in `invite` (step 2). A code has only
the characters `A-Z`, `a-z` and `0-9`. Remove the spaces at the start and the end of a typed code. If a code from
the URL, a form or a cookie has other characters, do not use it. Find each way to sign up in the product: the form,
and each sign-in provider, such as Google, that sends no form. The check must work for each of them:

- When the signup page opens with `invite`, keep the code in a first-party cookie for one hour. Use
  `SameSite=Lax`, so that the cookie comes back after the sign-in page of a provider. Do not make the cookie
  `HttpOnly`, because the page changes it. Also fill an "Invite code" field of the form from the URL. A friend can
  also type the code. When the friend types, changes or clears the code, put the new value into the cookie, with the
  same attributes. A provider such as Google sends no form, so its signup gets the code from the cookie.
- In the backend, read the code in the one place that creates each new user, for example a create hook of the
  auth library. Take it from the form, or else from the cookie.
- Some providers, for example Apple, come back to the product with a POST request. The browser does not send a
  `SameSite=Lax` cookie with that request. If the product creates the user in that request, use
  `SameSite=None; Secure` for the cookie.
- A sign-in link in an email can open in another browser, which does not have the cookie. If a sign-in link can
  sign up a new user or confirm an account, put the code into the link. When the product sends the link, take the
  code from the form or the cookie of that request. Add it to the URL of the link as `invite`.
- If a sign-in link signs up a new user, read the code only from that link. Do the same if a sign-in link is the
  first confirmation of an account. Do not keep the code with the email, because anyone can ask for a sign-in link
  to any email. A sign-in with an emailed one-time password can also confirm an account that already exists. Then
  take the invite code from the cookie, not from the account.

Some libraries, for example Supabase, create the user on their own server, which the browser can call without the
backend. For such a library:

- If signup needs an invite, call Talkable from the create hook of that server, for Supabase the "Before User
  Created" hook. It runs only when Supabase creates a user, and an error from it refuses the signup. The page sends
  the code in the signup data, for Supabase `options.data`. If the backend creates users with
  `auth.admin.createUser`, call Talkable there too, because that call runs no hook.
- A sign-in provider, such as Google, Apple or SSO, sends no signup data. So if signup needs an invite, the hook
  refuses a new user from it. The hook also runs for `inviteUserByEmail` and an anonymous sign-in. Tell the owner
  about each of these that the product uses.
- Make sure that each hook request has a valid signature. Give each refusal a `message`, because Supabase lets the
  signup continue if the error has no `message`.
- If anyone can sign up, keep no code with that user, also after a form signup. Put the code into the URL that the
  link opens at the end, for Supabase `emailRedirectTo`. If the link in the email has a `redirect_to` with no
  `invite`, the redirect allow list of Supabase does not accept the URL with `invite`. Then ask the owner to add that
  URL to the allow list, with `*` in place of the code. For example:
  `https://staging.example.com/auth/callback?invite=*`. Call Talkable when the backend first gets the session of that
  new user. For Supabase, the user is new if its `email_confirmed_at` is less than 10 minutes old and its
  `app_metadata` has no `talkable_checked`. At that first session, set `talkable_checked` in `app_metadata` with
  `auth.admin.updateUserById`, also if the product makes no call. Without this mark, a second sign-in in those 10
  minutes can call Talkable with the code of another advocate. If the session does not come from a link, take the
  code from the cookie.
- If anyone can sign up, get the "Confirm signup" and "Magic Link" templates of each environment from the repo or
  the owner. A template can build its own link, for example
  `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`, and a Send Email hook of Supabase always
  does. If one does, do not put the code into `emailRedirectTo` of any call. Then take the code from the cookie, also
  for a session from a link. In that case, the allow list step above and the rule that reads the code only from a
  sign-in link do not apply. Tell the owner that a link that opens in another browser, or after one hour, gives no
  code.

Call Talkable at the time that fits the signup of the product:

- If signup needs an invite, call after the product's own signup checks pass, just before it creates the account.
  Then a refused signup does not use an invite. But an email that nobody confirms uses an invite too.
- If anyone can sign up, call when the product creates the account. But if the product confirms emails, call when
  the friend confirms the email, so that a wrong email uses no invite. Keep the code with the new account until
  then. After a form signup, the product sends a link or a one-time password to confirm the email. That confirmation
  uses the code kept with the account, also if it signs the friend in. At the first confirmation, delete the code
  from the account, also if the product made no call. Then the product makes no call at a confirmation of a new
  email.
- A provider such as Google, or a sign-in link, also confirms the email. Some auth libraries do not mark the email
  as confirmed then, and some, for example better-auth, run no confirm hook. Auth.js marks the email as confirmed
  again at each sign-in by link. So if the call waits for the confirmation, call in each way that can confirm the
  email. Do this only at the first confirmation of a new account. An old account can also have an email that is not
  marked as confirmed.

Send the code and the email of the new account:

```http
POST https://www.talkable.com/api/v2/invite_redemptions
Authorization: Bearer TALKABLE_PRIVATE_API_KEY
Content-Type: application/json

{"site_slug": "TALKABLE_SITE_ID", "code": "INVITE_CODE", "email": "FRIEND_EMAIL", "ip_address": "FRIEND_IP"}
```

The answer is `{"ok": true, "result": {"status": "ok"}}`. Act on `result.status`:

- `ok`: continue the signup.
- `invalid` (for example an unknown code, or the advocate's own email) or `used_up` (the advocate has no invites
  left, or another email already used a code that the owner made): show the product's own message. If anyone can
  sign up, the friend gets in anyway. If signup needs an invite, refuse the signup.

Make this call only from the backend. Talkable refuses calls from a browser, and the key must never get to one.
`ip_address` is optional. A second call with the same code and email answers `ok` and records nothing new.

A site can make 120 calls a minute. Above that, Talkable answers 429 with `Retry-After: 60` and no `error_code`.
The backend does not wait: it treats this answer as a failed call. If the call fails, continue the signup without
the invite, but not if people can sign up only with an invite.

Last, tell later agent sessions about the campaign. Add the section below to the agent instructions of the repo,
for example `AGENTS.md` or `CLAUDE.md`. If the repo has none, create the file that your tool reads. Put the paths
and the pages of the product in place of the words in capitals. In the "Site IDs" line, write only the development
Site ID. Section 9 adds the Site ID of each other environment.

```markdown
## Talkable invite program

Signed-in users invite friends with a Talkable invite block. Each environment has its own Talkable site. Talkable
keeps the copy, the brand, the number of invites per user, the layout and the signup URL of the block. Do not
change them in this repo.

To change them, or to finish the setup, ask the owner. The owner can do it in Talkable, or ask an agent to
follow `https://www.talkable.com/skill.md` again.

- Block: BLOCK_FILE, on PAGES
- Signup check: SIGNUP_CHECK_FILE
- Site IDs: development DEVELOPMENT_SITE_ID, staging STAGING_SITE_ID, production PRODUCTION_SITE_ID.
- Config: `TALKABLE_SITE_ID` and `TALKABLE_PRIVATE_API_KEY` in each environment. ENV_FILE has the development
  values. Never print or commit a key.
```

## 4. Preview and test an invite

Do this step before the claim. Before the claim, the block shows only if all of these are true:

- A signed-in user opens the page, and the backend signs the email of that user (step 3).
- The page is on `localhost` or on a private IP address, for example `http://localhost:3000`. A dev name such as
  `app.test` does not work. Talkable reads the address of the page from the `Referer` header, and refuses a
  request with no `Referer`. The default `Referrer-Policy` of some frameworks stops the `Referer` to other sites,
  for example `no-referrer` of helmet for Express and `same-origin` of Django. For such a product, set
  `Referrer-Policy: strict-origin-when-cross-origin` only in the development config. Production does not need the
  `Referer`.
- The URL of the page has `?tkbl_campaign_id=CAMPAIGN_ID`. Use the URL that the page has after all redirects, for
  example `/app/acme`, not `/app`, because a redirect can drop the parameter.

On a public dev host, for example GitHub Codespaces, StackBlitz or another hosted IDE, the block does not show
before the claim. There, do steps 5 and 6 first, then this step, and then the launch.

Until the claim, Talkable refuses the other requests of the block, for example `shown`, `sharing_options` and
`track_share`. On a local page, the `timings` request fails with a CORS error, also after the claim and the
launch. Ignore the console errors for these requests.

The X, WhatsApp and Facebook buttons of the block have no link until the claim. The invite link of the block
also does not open until the claim: Talkable answers that the Site ID is not found. So test the invite with the
code of the block, on the signup page of the product (below). Before the claim, a sandbox makes at most
50 test advocates. After that, the block does not show until the claim.

If you can drive a browser (Playwright, Chrome DevTools or similar):

1. Start the dev server of the product, with the env file of step 3. If the dev server runs already, restart it,
   because most dev servers read the env file only at the start. Sign in as a test user. Sign one up if you need to.
2. Open each page with the block, and add `?tkbl_campaign_id=CAMPAIGN_ID` to its URL.
3. Make sure that the block shows the campaign, with the copy and the brand of the product. If not, send the
   change to the assistant as a fix (step 2).
4. Make sure that the console has no other errors. Fix what you find, then look again.
5. Copy the invite code from the block: the short code in the field below the invite link. Then sign out.
6. Open the local signup page with `?invite=` and that code. Sign up a second test user with another email.
   If the product confirms emails, confirm the email of this user too. Open the link or type the one-time password
   that the product sends, for example from the dev server log. Do not confirm it in the database, because that
   does not call Talkable. If you cannot get the link or the one-time password, test the code with the call
   below.
7. Make sure that the signup check got `ok`. For example, sign in as the first test user again, and open the first
   page with the block, with `?tkbl_campaign_id=`. The number of invites left in the block is now one less.

With no browser, ask the owner to open the preview URL, signed in, and to give you the code from the block.

If the product cannot sign up a user locally, test the code with this call. Expect
`{"ok":true,"result":{"status":"ok"}}`.

```bash
curl -sS -X POST https://www.talkable.com/api/v2/invite_redemptions -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $(jq -r '.result.private_api_key' "$SANDBOX_FILE")" \
  -d "{\"site_slug\":\"$SITE_ID\",\"code\":\"INVITE_CODE\",\"email\":\"friend@example.com\"}"
```

Launch refuses the campaign until one test invite gets `ok`. If you used this call, tell the owner that you did
not test the signup check of the product.

If anyone can sign up in the product, a person with no invite can also sign up. After the test invite gets `ok`,
close the open signup:

- If your tool runs each command with no question and no safety check, close it now. But if you used the call
  above, suggest it in the report instead. Do not close it if the owner said to keep the signup open.
- In a mode that asks before each command, suggest it in the report (step 5). Close it only after the owner says
  yes.

To close the signup, change the signup check to the rules of step 3 for a signup that needs an invite. Also refuse
a signup with no code, with a message that asks for an invite code. Users with an account still sign in. Do not
refuse the users that the product's own invite flows or its admins add.

Then test again with two new test users. Sign up one with no code: the product must refuse it. Sign up the other
with the code of the block: the signup check must get `ok`. If the block has no invites left, use the code of the
block of the second test user of item 6.

If signup needs an invite now, do not push or deploy this change. The owner deploys it to each environment after the
keys of that environment from section 9 are in its secrets.

### Common mistakes

Before the report, make sure that you did not make one of these mistakes:

- The block does not show after a change of the env file. Most dev servers read the env file only at the start.
  Restart the dev server after step 3, and after each change of `TALKABLE_SITE_ID` or `TALKABLE_PRIVATE_API_KEY`.
- A test friend sees no block after the signup. This is correct in test mode: until the launch, the block shows only
  on a page whose URL has `?tkbl_campaign_id=CAMPAIGN_ID`. To see the block of the friend, sign in as the friend
  and open the page with this parameter.
- A redirect removed `?tkbl_campaign_id=` from the URL. Use the URL that the page has after all redirects.
- The report does not name a check of this step that you did not do. Name each check that you did not do, with the
  reason.

## 5. Report, and ask one question

Tell the owner in one short message. The owner must see at once that the preview is ready. Start the message with
this line, word for word:

🎟️ Talkable invite program: ready to preview

On a public dev host, the preview works only after the claim (step 6). There, start with this line instead, and
tell the owner why:

🎟️ Talkable invite program: ready to claim

Then give only these three items. Do not tell what you did, what the assistant built, or which tests passed.

- The preview: the full preview URL of each page with the block, each on its own line, for example
  `http://localhost:3000/dashboard?tkbl_campaign_id=123`. Then the email and the password of the test user. Tell
  also that the invite link of the block works only after the claim.
- The signup: tell if signup needs an invite now, or if anyone can still sign up. If signup needs an invite now,
  tell the owner not to deploy this change to staging or production yet. First, the owner must put the keys of
  that environment from section 9 into its secrets.
- The invite codes: after the claim, the owner can make invite codes at `https://admin.talkable.com/sites/SITE_ID/campaigns/CAMPAIGN_ID/invite_codes`. Put the Site ID and
  the campaign ID in place of `SITE_ID` and `CAMPAIGN_ID`. Each code lets one person with no invite sign up.

If a step above tells you to tell the owner something more, add it in one line.

If the assistant set a value in place of a value of the brief, give the two values in one line. An example is a font
that Talkable does not have. Tell the owner that they can keep the value, or name another font for a fix (step 2).
After the claim, the owner can also upload the font on the Brand page of the site in Talkable.

End the message with one question: "Launch it now? Which email gets the Talkable login?" If anyone can still sign
up, add "Close the open signup?" to it.

If the repo does not show the site URL of each environment, ask for it in the same question, in one line. Ask
also about each environment that you are not sure of. Section 9 needs the site URL of each environment, with the
full URL of its signup page. If the owner gives a site URL of development, send it to the assistant as a fix
(step 2). Send also the full URL of the signup page on it.

If the owner does not want to launch yet, claim the sandbox anyway when they give the email. Talkable deletes an
unclaimed sandbox after 14 days with no use.

## 6. Claim the sandbox

Wait until each run of step 2 ends. The claim stops the sandbox key, and only that key can read the runs that it
started.

Before you send a link to an email for the first time, show the email to the owner and ask if it is correct. The
person who opens the link gets the program, so a typo can give it to a stranger. Then send a claim link to the
email:

```bash
curl -s -X POST https://www.talkable.com/api/v2/agent/sandbox/claim_link -H "Authorization: Bearer $API_KEY" \
  -H "X-Talkable-Client: $TALKABLE_CLIENT" -H 'Content-Type: application/json' -d '{"email":"OWNER_EMAIL"}'
```

Talkable sends the owner an email with a link. The link works for 24 hours
and one time. Tell the owner to open the link and press the button on the page. Then run this block. It asks
Talkable every 10 seconds if the owner pressed the button:

```bash
SECONDS=0
# Staging and production send the owner key of the development claim, from DEVELOPMENT_FILE of step 1. After a
# move into that account, Talkable gives the owner key of this site only to an agent with that key.
while :; do
  CLAIM="$(
    umask 077
    chmod 700 "${SANDBOX_FILE%/*}" && chmod 600 "$SANDBOX_FILE" || { echo failed; exit 0; }
    CLAIM_DIR="$SANDBOX_FILE.claim-$$-$RANDOM"
    mkdir "$CLAIM_DIR" || { echo failed; exit 0; }
    trap 'rm -rf "$CLAIM_DIR"' EXIT
    if [ "$TALKABLE_ENV" = development ]; then
      echo '{}'
    else
      jq -ce '{owner_key: (.result.api_key | select(type == "string" and length > 0))}' "$DEVELOPMENT_FILE" 2> /dev/null
    fi > "$CLAIM_DIR/request.json" || { echo no_development_key; exit 0; }
    curl -sS -o "$CLAIM_DIR/response.json" -X POST https://www.talkable.com/api/v2/agent/sandbox/claim \
      -H "Authorization: Bearer $API_KEY" -H "X-Talkable-Client: $TALKABLE_CLIENT" \
      -H 'Content-Type: application/json' --data-binary @"$CLAIM_DIR/request.json" 2> /dev/null || { echo failed; exit 0; }
    if jq -e '.ok == true and .result.status == "pending"' "$CLAIM_DIR/response.json" > /dev/null 2>&1; then
      echo pending
    elif jq -e --slurpfile claim "$CLAIM_DIR/response.json" \
        'if ($claim | length) == 1 and $claim[0].ok == true and $claim[0].result.status == "claimed"
            and ($claim[0].result | [.api_key, .site_slug, .private_api_key] | all(type == "string" and length > 0))
         then .result += ($claim[0].result | {api_key, site_slug, private_api_key})
         elif ($claim | length) == 1 and $claim[0].ok == true and $claim[0].result.status == "moved"
            and ($claim[0].result.site_slug | type == "string" and length > 0)
         then .result.site_slug = $claim[0].result.site_slug
         else error("Claim failed") end' "$SANDBOX_FILE" > "$CLAIM_DIR/credentials.json" 2> /dev/null &&
        mv -f "$CLAIM_DIR/credentials.json" "$SANDBOX_FILE"; then
      jq -r '.result.status' "$CLAIM_DIR/response.json"
    else
      jq -er '.error_code | strings' "$CLAIM_DIR/response.json" 2> /dev/null || echo failed
    fi
  )"
  if [ "$CLAIM" != pending ] || [ "$SECONDS" -ge 75 ]; then break; fi
  sleep 10
done
echo "Claim: $CLAIM"
case "$CLAIM" in
  claimed)
    API_KEY="$(jq -er '.result.api_key' "$SANDBOX_FILE" 2>/dev/null)"
    SITE_ID="$(jq -er '.result.site_slug' "$SANDBOX_FILE" 2>/dev/null)"
    echo "Site ID: $SITE_ID" ;;
  moved)
    SITE_ID="$(jq -er '.result.site_slug' "$SANDBOX_FILE" 2>/dev/null)"
    echo "Site ID: $SITE_ID" ;;
  pending) ;;
  *) echo 'Saved credentials were not changed.' >&2; exit 1 ;;
esac
```

One run of the block ends in less than 2 minutes. It prints `Claim:` and the state of the claim:

- `claimed`: the owner pressed the button. The block saved three values in `SANDBOX_FILE` in one atomic step.
  They are the new key of the owner (`api_key`), the Site ID (`site_slug`) and the private API key of the site
  (`private_api_key`). The block kept the other values of the file. It loaded `API_KEY` and `SITE_ID`, and printed
  the Site ID but not the keys. After a move into the account of the development claim, the private API key is new,
  and the old one does not work. Talkable gives the owner key one time, so do not run the block again. The sandbox
  key does not work now. A fix run (step 2) uses the owner key from that file. Tell the owner the email of the
  Talkable login that has the program now: the email of the link.
- `moved`: the owner pressed the button, and the program moved into the Talkable account of the owner. Talkable
  gave you no key, because you sent no key of that account. The block saved the new Site ID in `SANDBOX_FILE` and
  printed it. The keys in that file do not work now. For this environment, do not create a sandbox again, and do not
  run step 7. Section 9 still makes the sandboxes of the other environments. Tell the owner:
  - The program is in their Talkable account, and Talkable gave you no key to it.
  - They copy the new private API key from the "API integration" page of the site in Talkable. For development, it
    goes into the env file of step 3, in place of the old key. For staging and production, it goes into the secrets
    of that environment.
  - They test one invite, and then they launch the campaign in Talkable.
  - For later changes, you use the Talkable MCP server after they sign in there, as "Change the campaign later"
    says.
- `pending`: the owner did not press the button yet. Run the block again. If 15 minutes went by after the email,
  stop. Ask the owner if the email came, and run the block again when they answer.
- `link_expired`: no link is live. The link is older than 24 hours, or no
  link went out. Send a new link with the first block, then run this block again.
- `unauthorized`: the key is wrong, or the sandbox has an owner already. A call at the moment of the press can get
  it too, so run the block one time again, and do what the new state says. If it prints `unauthorized` again, keep
  `SANDBOX_FILE`. Then call `list_campaigns` (step 2):
  - If it works, an earlier session claimed the sandbox. Continue as after `claimed`.
  - If it answers `Unauthorized` and the owner has a Talkable login, use the Talkable MCP server, as "Change the
    campaign later" says. Call `list_campaigns` there, with `SITE_ID` as `site_slug`. If it works, continue as
    after `moved`, with the Site ID of its note, if it has one.
  - If the owner has no login, or the server does not find the site, you cannot continue with this sandbox. Tell
    the owner. Create a new sandbox only if the owner says yes. To create it, run the block of step 1 for this
    environment and remove `SANDBOX_FILE` in the same command. Then run the block again with the line
    `NO_NEW_SANDBOX=` (empty) at its top.
  - If it fails in another way, call it again later.
- `no_development_key`: only staging and production get it. This computer has no saved key of the development
  claim for this product, so the block did not claim. Stop, and tell the owner where section 9 must run.
- `failed`: the call or its answer failed. Run the block again.

A failed claim keeps the old file. Do not create another sandbox, except as the `unauthorized` state says.

The page of the link tells the owner what happens, and the owner can tell you:

- If the email has no Talkable login, the button makes one and signs the owner in. The button also accepts the
  Terms. The login has no password: in another browser, the owner uses "Forgot password" on the login page.
- If the email has a Talkable login that can take the program, the owner signs in first. Then the button moves the
  program into the account of that login.
- If the email has a login that cannot take the program, for example a login with 2FA or SSO, the page has no
  button. Ask the owner for another email, and send a new link to it. Or tell them to ask support@talkable.com to move
  the program into their account.
- If the account cannot take the program as it is, the page says why. Tell the owner to ask support@talkable.com to move
  the program, and stop.

Errors of the first block:

- `invalid_email`: the email is not one plain ASCII address.
- `rate_limited`: each sandbox gets 3 links an hour, and each email gets 4. Tell the owner. Send the link again
  after 1 hour.
- `unauthorized`: the owner can have pressed the button of an earlier link. Run the claim block once.

For development, set `TALKABLE_SITE_ID` in the env file of step 3 to the Site ID that the claim printed. Use a
command that changes only the `TALKABLE_SITE_ID` line and does not print the file. Update the development Site ID
in the "Site IDs" line of the "Talkable invite program" section (step 3) too, because the claim can change it. The
old Site ID works for now. After `claimed`, the private API key does not change. After `moved`, the owner puts the
new private key into the env file.

Then restart the dev server, because it reads the env file only at the start.

## 7. Launch

Launch only after the owner says yes to the launch in the chat. Then run this, with the campaign ID of step 2 in
place of `CAMPAIGN_ID`:

```bash
curl -s -X POST https://www.talkable.com/api/v2/agent/sandbox/launch -H "Authorization: Bearer $API_KEY" \
  -H "X-Talkable-Client: $TALKABLE_CLIENT" -H 'Content-Type: application/json' -d '{"campaign_id":CAMPAIGN_ID}'
```

The answer is `{"ok": true, "result": {"campaign_id": ...}}`. The campaign is live. Talkable deletes the test
offers and referrals, as the launch in the admin does. It also deletes the signup events of test friends whose
emails were QA emails at the signup, for example on `example.com`.

Tell the owner. Signed-in users of this environment see the block. For staging and production, that is after the
deploy.

Errors:

- `unauthorized`: `API_KEY` is not the key from the claim. Run the block of step 1 again to load it. If the claim
  printed `moved`, or if the key still does not work, tell the owner to launch the campaign in Talkable.
- `campaign_not_found`: use the campaign ID of step 2.
- `not_invite_only`: launch only the invite-only campaign.
- `not_in_test_mode`: the campaign is live already. Stop.
- `url_not_production`: send the site URL of this environment and the full URL of its signup page to the
  assistant as a fix (step 2). If you do not have them, ask the owner.
- `no_invite_code`: the invite links of the campaign do not carry the advocate's code. Send a fix brief (step 2)
  that puts `invite: advocate_offer.short_url_code` back into the friend destination of the campaign. Then launch
  again.
- `no_test_redemption`: do the test invite of step 4 first. A claim that moved the program into the account of a
  Talkable login can delete the test invite. Then test one invite again, and launch.
- `campaign_invalid`: send `error_message` to the assistant as a fix (step 2), then try again.

## 8. Tell the owner what to deploy

If step 7 launched the campaign, tell the owner that it is live in development. If not, tell the owner that it is
not live yet, and how to launch it. If the development claim printed `moved`, the owner launches it in Talkable.
Then tell what comes next: section 9 makes the site of each other environment. Each of them needs
`TALKABLE_SITE_ID` and `TALKABLE_PRIVATE_API_KEY` in the secrets of that environment, and a deploy of the product.
Do not show a key.

If you closed the open signup (step 4), tell the owner these facts:

- After a deploy of this change, a new user can sign up only with an invite code. Users with an account still sign
  in.
- Until the campaign of an environment is live, its users have no invite links to give. Until then, only the codes
  that the owner makes let new users in.
- Do not deploy this change to staging or production before the keys of that environment from section 9 are in
  its secrets. Until then, the signup check fails there and refuses every new user, also with a code.

Just above the last line, give the link where the owner makes invite codes: `https://admin.talkable.com/sites/SITE_ID/campaigns/CAMPAIGN_ID/invite_codes`. Put the Site ID
that the claim printed and the campaign ID in place of `SITE_ID` and `CAMPAIGN_ID`.

The last line of the message is the full preview URL of the first page with the block, for example
`http://localhost:3000/dashboard?tkbl_campaign_id=123`. Write nothing after it. When the owner opens it signed in,
they see the campaign on their own site. After section 9, the same paths work in the other environments.

## 9. Staging and production

Do this section for each other environment of step 1: staging first, if the repo has it, then production. Do one
environment at a time. The block and the signup check are in the code already, so do not change them. Do this
section on the computer and in the product directory of the development claim, because the claim block sends the
owner key that the development claim saved there.

1. Run the block of step 1 with `TALKABLE_ENV=staging` or `TALKABLE_ENV=production` at its top. It creates the
   sandbox of that environment. If it prints "No saved development key for this product.", stop, and tell the owner
   where this section must run.
2. Send the brief of that environment (step 2). Give its site URL, the URL of its signup page, and a product name
   with the environment, for example `Acme Notes (staging)`. For production, give the plain name. Give also the
   other lines of step 2. Wait for the run, and get the campaign ID.
3. Claim the sandbox (step 6) with the email of the development claim. Tell the owner that this link moves the
   site into the same account. If the owner is still signed in to Talkable in that browser, the button is one
   click. The claim block sends the owner key of the development claim. Because of this, Talkable gives you the owner
   key of this site too. If the block prints `moved`, do what the `moved` state of step 6 says. Then do only items 4
   and 5 of this list, because the owner tests one invite and launches in Talkable.
4. Add the Site ID that the claim printed to the "Site IDs" line of the "Talkable invite program" section (step 3).
   Do it now, because a later session finds the site of this environment only in that line.
5. Tell the owner to set these values in the secrets of that environment. `TALKABLE_SITE_ID` is the Site ID that the
   claim printed. `TALKABLE_PRIVATE_API_KEY` is the private key of the site. The owner copies the private key from the
   "API integration" page of that site in Talkable. Do not write this key into a file of the repo or into an env
   file. Then the owner deploys the product.

   Give the owner the link where they make invite codes for this environment: `https://admin.talkable.com/sites/SITE_ID/campaigns/CAMPAIGN_ID/invite_codes`. Put the Site ID
   that the claim printed and the campaign ID in place of `SITE_ID` and `CAMPAIGN_ID`.
6. After the deploy, preview the block and test one invite on that host, as in step 4. Use the private key of
   `SANDBOX_FILE` for the call of step 4 that tests a code.
7. Launch (step 7) only after the owner says yes in the chat.

## Change the campaign later

Do this section only in a later session, after an earlier session added the campaign. Do not change the block or
its copy in the code.

If the "Talkable invite program" section (step 3) has no "Site IDs" line, it is from before environments. Do the
change only for development, with the value of `TALKABLE_SITE_ID` as the development Site ID.

If the "Talkable invite program" section of the repo tells agents to get this skill with `curl`, an older version
of this skill wrote it. Write that section again as step 3 says, with the same values.

Do each change in each environment that has a Site ID in the "Site IDs" line, development first. For each
environment, run the block of step 1 with the line `NO_NEW_SANDBOX=1` at its top. For staging or production, add the
line of its `TALKABLE_ENV` too. The block loads the saved key of that environment, but it does not create a sandbox.
Then get the invite-only campaign with `list_campaigns` (step 2), and act on its `status`:

- `Test`: the campaign is in test mode. For development, continue from step 4. For staging
  or production, continue from item 5 of section 9.
- Another status, for example `Live`: send only a change of the brand to our assistant, as
  a fix brief (step 2). Tell the owner to make each other change in Talkable.

If the block of step 1 prints "No saved key for this product.", add the Talkable MCP server, `https://www.talkable.com/mcp`, to
your tool. Ask the owner to sign in with their Talkable login. Then use the tools of that server for the steps
above, with the Site ID of that environment (from the "Site IDs" line) as `site_slug`. Without the saved key, step 7
cannot launch the campaign, so tell the owner. If you cannot add the server, ask the owner to make the change in
Talkable. For the brand, the owner uses the Brand page or the AI Assistant of the site there.

If the owner has no Talkable login yet, tell the owner. Create a new sandbox only if the owner says yes.

If the campaign tools answer `Unauthorized`, run the claim block of step 6 one time, because the owner can press the
button of the link after the end of an earlier session. If it prints `claimed`, continue with the owner key that it
loaded. If not, do what step 6 says for that state.

Section 9 writes the Site ID of staging or production only after the claim. But the owner can press the button of
the link after the end of the session. Thus, for staging, then for production, if the "Site IDs" line has no Site ID
of that environment, do these steps:

1. Put the line `NO_NEW_SANDBOX=1` and the line of its `TALKABLE_ENV` at the top of the block of step 1. Run the
   block. If it prints "No saved key for this product.", skip that environment.
2. Run the claim block of step 6 one time. Then act on the state that it prints:
   - `claimed`: continue from item 4 of section 9.
   - `moved`: do what item 3 of section 9 says for `moved`.
   - `link_expired`: continue from item 3 of section 9.
   - Another state: do what step 6 says for that state.
