# Install Openclaw To Yc

> Autonomously set up an OpenClaw bot on a fresh Yandex Cloud VM in Kazakhstan (kz1-a, Karaganda). Asks the user for exactly two things — a Telegram bot token and one of three LLM access options (Anthropic API key, OpenRouter API key, or OpenAI Codex OAuth via ChatGPT Plus/Pro subscription) — then handles VM creation, hardening, OpenClaw install, CEO AI OS workspace seeding, Telegram pairing, chat_id auto-detection, and bot-reply verification on its own. The only other actions the user performs are pressing /start in Telegram once and (if Codex) confirming a device code on auth.openai.com. Use when the user says install OpenClaw to Yandex Cloud, deploy OpenClaw to YC Kazakhstan, set up my CEO bot in YC KZ, I am at OpenClaw workshop and need my own bot, create a Yandex Cloud VM for OpenClaw, or any close paraphrase. Targets a ~15-minute end-to-end run for non-DevOps users (founders, CEOs, marketing leads). Supports two modes of accessing Yandex Cloud — Plan A (the user's own YC Kazakhstan account via OAuth) and

- Skill: `codealive-ai/install-openclaw-to-yc` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add codealive-ai/install-openclaw-to-yc`
- Raw SKILL.md: https://api.skillmd.com/api/skills/codealive-ai/install-openclaw-to-yc/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- License: MIT
- Author: CodeAlive-AI (https://skillmd.com/u/codealive-ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/codealive-ai/install-openclaw-to-yc

---


# Install OpenClaw to Yandex Cloud (Kazakhstan)

A wizard that takes a non-DevOps user from zero to a working OpenClaw bot on a fresh Yandex Cloud Kazakhstan VM in ~15 minutes. **The user does exactly two-or-three things**: paste a Telegram bot token, paste an LLM API key (or say "Codex" for OAuth via ChatGPT subscription), press `/start` in Telegram once, and — only for Codex — confirm a device code on auth.openai.com. Everything else is silent.

## Operating principles (the "don't bother the user" rules)

These rules override the rest of the document. Read them first.

1. **Two questions. Total.** The only inputs you ask the user for are the **Telegram bot token** and the **LLM access** (one of three options — see Step 1). Everything else — VM name, zone, image, SSH key, security-group ingress, **chat_id** — is decided silently from safe defaults or auto-detected.
2. **Never ask "are you sure"** for actions inside this wizard's own scope (creating its own VM, its own security group, its own bot pairing). Only confirm if you're about to destroy something the user might want to keep (an existing VM with the same name).
3. **Never show shell commands, flags, paths, or stack traces** to the user unless they explicitly ask "what did you run?". Progress is plain language: "Создаю VM…", "Ставлю OpenClaw…", "Проверяю что бот отвечает…".
4. **Validate inputs upfront** with a one-call test (Telegram `/getMe`, LLM key probe). Don't burn 15 minutes on a VM bootstrap with a bad key.
5. **Auto-fix prerequisites silently** when it's safe — install `yc` CLI, generate an SSH key, switch endpoint to Kazakhstan. Only stop and ask the user when something *can't* be done without their input (`yc init` OAuth login, no billing account).
6. **One language — and the bot speaks it too.** If the user wrote to the agent in Russian, all wizard prompts are in Russian, **and** the OpenClaw bot itself is configured to reply in Russian. Detect the user's language from their first few messages, pass it through to cloud-init as `{{USER_LANGUAGE}}` (ISO 639-1: `ru`/`en`/`kk`/...), and the bootstrap script appends a localization block to the bot's `USER.md`. Default if you can't tell: `ru` (workshop audience).
7. **No emojis** in user-facing text unless the user used them first.

## Do NOT ask the user for these (hard override)

These are the things the wizard has been observed asking by mistake. Don't:

| Don't ask | Why | What to do instead |
|---|---|---|
| **Telegram chat_id** | Auto-detected in Step 4 from `getUpdates` after the user presses `/start`. Asking for it makes the user open `@userinfobot`, copy a number, paste — pure friction. | Poll `https://api.telegram.org/bot<TOKEN>/getUpdates` every 2s in Step 4. Pluck `result[0].message.chat.id`. |
| **VM name** | Default is `openclaw-bot`. If taken, append `-<random4>`. | Set silently. Tell user the name only in the final summary. |
| **SSH IP restriction** | There is none — SSH ingress is always open to `0.0.0.0/0`. User and the managing automation agent both have dynamic IPs, and an automation agent making frequent SSH calls must never be IP-banned. Security = key-only auth + fail2ban (bans only on FAILED auth). | Open `0.0.0.0/0` silently. |
| **Zone / region / subnet / image / VM shape** | All hard-coded for YC Kazakhstan (kz1-a, ubuntu-2404-lts, standard-v3, 2 vCPU / 4 GB / 30 GB). | Don't surface to user. |
| **Linux username on the VM** | Always `openclaw`. | Use it without asking. |
| **Anthropic / OpenRouter / OpenAI billing balance** | Caught upfront in Step 1 by a probe call. If insufficient, fail fast with a one-line message — don't ask "are you sure you topped up?". | Probe call before VM create. |

If you catch yourself drafting a question outside the two allowed inputs, re-read this section. The wizard's whole point is autonomy.

## When to invoke

Trigger on: "install OpenClaw on Yandex Cloud", "set up my bot in YC Kazakhstan", "OpenClaw workshop workshop", "deploy OpenClaw remotely", "поставь себе openclaw", "разверни мне бота в Yandex Cloud", "у меня workshop-ключ", "вот bundle от организатора", and close paraphrases.

Do NOT use this skill for:
- Local-machine OpenClaw install → use `openclaw/install.sh` in this repo (the user runs it on their laptop)
- **Hetzner Cloud** → use `install-openclaw-to-hetzner` (sibling skill, same shape, different CLI)
- AWS / GCP / Azure / other Yandex Cloud regions → this skill is hard-coded for Yandex Cloud Kazakhstan (kz1-a); no sibling skills exist yet for those clouds
- Adding a second agent or a second bot to an existing OpenClaw VM → out of scope
- Preparing the workshop *as an organizer* (creating N folders + keys for participants) → use `prepare-yc-workshop` (the matching organizer-side skill) — this skill is for the *participant*

## Two access modes (Plan A and Plan B)

This skill works in two modes — picked silently at Step 0 from what the user has on hand. The user is shown the choice **only once** in Step 1; after that, both branches converge and the rest of the wizard is identical.

| Mode | When | What the user supplies | Skill does |
|---|---|---|---|
| **Plan A — own YC account** (default) | The user has (or is willing to create) a Yandex Cloud Kazakhstan account. | OAuth token from `oauth.yandex.kz` (asked once in Step 0d). | `yc init`-equivalent on a wizard-owned profile + own cloud-id/folder-id. |
| **Plan B — workshop bundle** | The user is at a workshop, the organizer DM'd them a `bundle-NN.json` file, and they don't want / don't have time to set up their own YC. | Path to the `bundle-NN.json` file the organizer sent them. | Parses the bundle, configures `yc` with the embedded service-account key + cloud-id + folder-id. No OAuth, no personal YC account needed. |

Plan B is recognised by detecting a workshop bundle file in any of these ways (auto-detected in Step 0.5 below):

1. The user pasted a file path that resolves to a JSON whose `$schema` starts with `openclaw-workshop-bundle@`.
2. The user said one of: "у меня workshop-ключ", "вот bundle от организатора", "I have a workshop key", "organizer gave me a key file", "у меня нет своего Yandex Cloud, есть только ключ от воркшопа".
3. A file matching `bundle-*.json` is present in the user's current working directory or `~/Downloads` (offered with a one-line confirmation).

If none match, the wizard defaults to Plan A.

After Step 0.5 the two branches converge — Steps 1, 2, 3, 4, 5 are identical regardless of mode. Plan A and Plan B both end with a working YC profile pointing at one folder; everything downstream just uses that.

## Inputs (the only two questions you ask)

| # | Input | How user gets it (paste this verbatim in your prompt) |
|---|---|---|
| 1 | **Telegram bot token** | Open [@BotFather](https://t.me/BotFather) in Telegram → send `/newbot` → pick any display name → pick a username ending in `bot` → copy the token (looks like `7892341234:AAFhJk2mNopq…`). |
| 2 | **LLM access** — one of three options | See the table below. The user picks ONE option, the wizard auto-detects which one from the format of what they paste. |

### LLM access options (the user picks one)

| Option | What user pastes | Detection signal | Cost / requirements |
|---|---|---|---|
| **A. Anthropic API key** | Key starting with `sk-ant-…` from [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) | Prefix `sk-ant-` | ≥$5 credit on [console.anthropic.com/settings/billing](https://console.anthropic.com/settings/billing). Best raw quality. Pay-as-you-go (~$3 per million input tokens for Sonnet 4.6). |
| **B. OpenRouter API key** | Key starting with `sk-or-…` from [openrouter.ai/keys](https://openrouter.ai/keys) | Prefix `sk-or-` | ≥$5 credit on OpenRouter. Unified access to Anthropic + OpenAI + 200 other models through one key, ~5% markup over native. Good if user wants to A/B different models later. |
| **C. OpenAI Codex via ChatGPT** | The literal word `Codex` (or `codex`, `chatgpt`, `oauth`) — **not** a key | Token doesn't start with `sk-` | Active **ChatGPT Plus ($20/mo)** or **Pro ($200/mo)** subscription. After VM bootstrap, the wizard prompts the user once on auth.openai.com with a device code — no API key needed, no metered billing. Plus gives the `gpt-5.4` family; Pro adds `gpt-5.5`. |

Order of recommendation in the prompt: **A → C → B** for first-timers. A is the simplest happy path with the best Anthropic model. C is best for users who already pay for ChatGPT and want zero added bill. B is the power-user choice.

Everything below is decided **without asking the user**:

| Decided silently | Value | How |
|---|---|---|
| VM name | `openclaw-bot` (or `openclaw-bot-<random4>` if taken) | If `yc compute instance get --name openclaw-bot` returns a result, append a random 4-char suffix and try again. |
| Zone | `kz1-a` | Only zone in YC Kazakhstan. |
| Subnet | `default-kz1-a` | Auto-provisioned in any new KZ folder. |
| OS image | `ubuntu-2404-lts` (latest) | From `standard-images` folder. |
| VM shape | `standard-v3`, 2 vCPU, 4 GB RAM, 30 GB SSD | Matches the reference deployment. |
| Public IP | yes, ephemeral IPv4 NAT | Simplest path for SSH from anywhere. |
| Linux user | `openclaw` (sudo, no password) | Created by cloud-init. |
| SSH key | `~/.ssh/id_ed25519.pub` (or auto-generate if missing) | `ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""` if absent. |
| SSH ingress | `0.0.0.0/0` (open, no per-IP lock) | User/agent IPs are dynamic and the managing automation agent must not be IP-banned. Security is key-only auth + fail2ban (bans only on FAILED auth). |
| Outbound | open to anywhere | OpenClaw needs Anthropic, Telegram, OpenAI, OpenRouter, etc. — locking down by domain is fragile. |
| Telegram chat_id | **auto-detected** after first `/start` | Poll `https://api.telegram.org/bot<TOKEN>/getUpdates`. Never asked. |
| Primary model | Depends on chosen LLM option, see table below | |
| Fallback models | Depends on chosen LLM option, see table below | |

### Default models per LLM option

| Option | `agents.defaults.model.primary` | `agents.defaults.model.fallbacks` |
|---|---|---|
| A. Anthropic | `anthropic/claude-sonnet-4-6` | `["anthropic/claude-haiku-4-5"]` |
| B. OpenRouter | `openrouter/moonshotai/kimi-k2.6` | `["openrouter/openai/gpt-5.5", "openrouter/anthropic/claude-haiku-4-5"]` |
| C. OpenAI Codex | `openai/gpt-5.5` (Pro) or `openai/gpt-5.4` (Plus) | `["openai/gpt-5.4"]` |

For C, the wizard probes the subscription tier after OAuth completes — if `openai/gpt-5.5` isn't in `openclaw models list --json`, it uses `openai/gpt-5.4` as primary. (Codex models live in the `openai/` namespace, backed by the `openai-codex` OAuth profile — verified on a live 2026.5.27 bot.)

## Wizard flow

### Step 0 — Silent preflight (no user-facing output unless something breaks)

Run in order. Each block has a silent auto-fix path; only fall through to a user prompt when no silent path exists.

**Critical Yandex Cloud Kazakhstan rules** — read before touching `yc`:

- KZ has its own endpoint `api.yandexcloud.kz:443`. The Russian endpoint `api.cloud.yandex.net:443` and the KZ endpoint serve **different** clouds, folders, and OAuth realms. Mixing them is the #1 cause of "wizard says no billing / no cloud" when both exist.
- Setting `yc config set endpoint api.yandexcloud.kz:443` **does NOT** auto-update `cloud-id` or `folder-id`. If you only flip the endpoint and don't re-set cloud-id/folder-id, every subsequent `yc compute / vpc / resource-manager` command will fail with NotFound, because those IDs belong to the RU realm.
- The KZ endpoint exposes a **smaller command set**: `iam`, `quota-manager`, `resource-manager`, `compute`, `vpc`, `dns`, `managed-kubernetes`. **`yc billing` does NOT exist on the KZ endpoint.** Calling `yc billing account list` always errors out — don't use it for any check.
- Use **named profiles** (`yc config profile create/activate`) instead of editing the active config. The user almost certainly already has profiles for their other clouds — don't clobber them.

**Step 0.5 — Detect mode (Plan A vs Plan B).** Runs **first**, before anything else in Step 0.

Plan B short-circuits steps c, d, e (profile, OAuth, cloud-id/folder-id resolution) because the bundle already has all of that baked in. Plan A keeps them.

Detection order:

1. **Explicit file path** in what the user said. Resolve the path; if it's a readable JSON whose `$schema` field starts with `openclaw-workshop-bundle@`, set `MODE=plan-b` and `BUNDLE_PATH=<path>`.
2. **Phrase match.** If the user wrote any of these (or close paraphrase) in the activation message:
   - "У меня workshop-ключ" / "вот bundle от организатора" / "ключ от воркшопа"
   - "I have a workshop key" / "organizer gave me a key file"
   - "Bundle from the organizer is here:" → followed by path
   - Then ask **one** clarifier: "Где лежит файл `bundle-NN.json` от организатора? (можешь перетащить файл в чат, или просто путь)" — accept the path, validate the `$schema`, set `MODE=plan-b`.
3. **Auto-discovery.** Glob `bundle-*.json` in `$PWD`, `~/Downloads`, `~/Desktop`. If exactly one match whose `$schema` starts with `openclaw-workshop-bundle@`, ask once: "Нашёл workshop-ключ `bundle-NN.json` в `<path>`. Это от организатора? (да / нет)". On "да" → `MODE=plan-b`. On "нет" → continue to next step.
4. **Default.** No bundle detected → `MODE=plan-a`. Don't ask "do you have a workshop key?" upfront — that's friction for the >50% of users who have their own YC and would treat the question as noise.

Schema sanity check on the bundle file:

```bash
SCHEMA=$(jq -r '."$schema" // empty' "$BUNDLE_PATH" 2>/dev/null)
[[ "$SCHEMA" =~ ^openclaw-workshop-bundle@ ]] \
  || { say "Это не похоже на workshop-bundle от организатора. Проверь, что прислали правильный файл."; stop; }

# Required fields
for f in cloud_id folder_id zone endpoint key; do
  jq -er ".${f}" "$BUNDLE_PATH" >/dev/null \
    || { say "В bundle не хватает поля ${f}. Попроси у организатора новый файл."; stop; }
done
```

On any validation failure for Plan B, tell the user in one sentence what's wrong, advise asking the organizer, and stop — don't silently fall back to Plan A. Falling back would burn 10 minutes asking for OAuth they don't have.

**If `MODE=plan-b`**: configure `yc` from the bundle and skip directly to Step 0a, then `f`, then `g` (skipping `c`, `d`, `e`):

```bash
# Wizard-owned profile so we don't disturb the user's other yc setups
yc config profile create openclaw-workshop 2>/dev/null || true
yc config profile activate openclaw-workshop

# Carve the SA key into the shape `yc config set service-account-key` expects
KEY_FILE="$(mktemp -t openclaw-sa-key.XXXXXX.json)"
jq '.key' "$BUNDLE_PATH" > "$KEY_FILE"
chmod 600 "$KEY_FILE"

yc config set service-account-key "$KEY_FILE"
yc config set endpoint            "$(jq -r '.endpoint'  "$BUNDLE_PATH")"
yc config set cloud-id            "$(jq -r '.cloud_id'  "$BUNDLE_PATH")"
yc config set folder-id           "$(jq -r '.folder_id' "$BUNDLE_PATH")"
yc config set compute-default-zone "$(jq -r '.zone'      "$BUNDLE_PATH")"

# Verify it actually works — one read call against the configured folder
yc compute zone list --format json | jq -e '.[] | select(.id=="kz1-a")' >/dev/null \
  || { say "Ключ от организатора не работает (или истёк). Напиши организатору: попроси новый bundle."; stop; }
```

Plan-B-specific failure modes:
- **Schema older than `@1`** → bundle predates this version of the skill. Ask user to refresh from organizer; don't try to be smart.
- **Schema newer than what this skill recognises** → also ask for guidance from organizer (they updated `prepare-yc-workshop` past us). Bundle may still work — try the steps above and surface YC errors plainly.
- **`yc compute zone list` returns empty** → key was revoked or folder deleted. Tell the user: "Кажется, организатор уже свернул облако воркшопа. Свяжись с ним."
- **`yc compute zone list` returns `ru-central1-*`** instead of `kz1-a` → bundle's `endpoint` field was wrong. Surface plainly; this is an organizer-side bug.

**On `MODE=plan-b`, skip Step 0c, 0d, 0e entirely** and proceed to 0a → 0b → 0f → 0g. The bundle already contains a valid token-equivalent + cloud-id + folder-id, so re-asking for OAuth would be cruel and pointless.

**On `MODE=plan-a`, proceed normally** through Step 0a-g as written.

---

**a. Companion skill `openclaw-guide` must be loaded.** Probe the agent's skill loader. If it's not present, run `npx skills add CodeAlive-AI/ceo-ai-os@openclaw-guide -g` silently. If `npx` is unavailable, tell the user (one sentence) to install both skills manually, then stop.

**b. `yc` CLI installed.** `yc --version`. If missing: silently `curl -fsSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash -s -- -i $HOME/yandex-cloud -n` and add `~/yandex-cloud/bin` to PATH for this session.

**(c, d, e are Plan A only — skip on `MODE=plan-b`, which configured the profile from the bundle in Step 0.5.)**

**c. Activate a dedicated Kazakhstan profile.** Use a wizard-owned profile so we don't disturb the user's existing setup:

```bash
PROFILE_NAME=openclaw-kz
CURRENT_PROFILE=$(yc config profile list 2>/dev/null | awk '/ACTIVE/{print $1}')

# If the user is already on a profile pointing at KZ, just use it.
if [[ "$CURRENT_PROFILE" != "$PROFILE_NAME" ]]; then
  if [[ "$(yc config get endpoint 2>/dev/null)" != "api.yandexcloud.kz:443" ]]; then
    # Create/activate our own profile rather than mutating the user's active one
    yc config profile list 2>/dev/null | grep -qE "^${PROFILE_NAME}\b" \
      || yc config profile create "$PROFILE_NAME"
    yc config profile activate "$PROFILE_NAME"
  fi
fi
```

After this block, the active profile is either the user's pre-existing KZ-pointing profile (preserve their settings — they know what they're doing) or our fresh `openclaw-kz` profile (we'll fill it in steps d-f).

**d. OAuth token + endpoint on the active profile.** Check `yc config get token` and `yc config get endpoint`:

- Both already set, endpoint is KZ → ✅ skip ahead.
- Endpoint set to KZ but no token → ask the user once (see below).
- Token set but wrong endpoint → silently `yc config set endpoint api.yandexcloud.kz:443`.
- Nothing set (fresh profile) → ask the user once for an OAuth token.

The OAuth ask is **the only mandatory user prompt in Step 0**. Say exactly:

> Для работы с Yandex Cloud Kazakhstan нужен OAuth-токен (один раз). Открой в браузере:
>
> https://oauth.yandex.kz/authorize?response_type=token&client_id=1a6990aa636648e9b2ef855fa7bec2fb
>
> Войди под своим Yandex ID, разреши доступ. После редиректа браузер покажет URL вида `https://oauth.yandex.kz/verification_code#access_token=y0_XXXXXX…&token_type=bearer&expires_in=...`. Скопируй значение `access_token=…` (длинная строка между `=` и `&`) и пришли мне.

After receiving the token: `yc config set token <token>`, then set the endpoint **and** the zone in the same breath:

```bash
yc config set endpoint api.yandexcloud.kz:443
yc config set compute-default-zone kz1-a
```

`compute-default-zone` is decoupled from endpoint — `yc config set endpoint` doesn't touch it. Without it set, any `yc compute *` command that omits `--zone` falls back to whatever was there before (often `ru-central1-a` from a previous RU init) and fails with "zone not found".

**e. cloud-id and folder-id.** After step d, the profile has a valid token. Now resolve the IDs:

```bash
CLOUD_ID=$(yc config get cloud-id 2>/dev/null || true)
if [[ -z "$CLOUD_ID" ]]; then
  CLOUD_ID=$(yc resource-manager cloud list --format json | jq -r '.[0].id // empty')
  [[ -n "$CLOUD_ID" ]] && yc config set cloud-id "$CLOUD_ID"
fi

FOLDER_ID=$(yc config get folder-id 2>/dev/null || true)
if [[ -z "$FOLDER_ID" ]]; then
  FOLDER_ID=$(yc resource-manager folder list --cloud-id "$CLOUD_ID" --format json | jq -r '.[0].id // empty')
  [[ -n "$FOLDER_ID" ]] && yc config set folder-id "$FOLDER_ID"
fi
```

Failure modes:
- `cloud list` returns empty → the OAuth token is for a Yandex ID that has no clouds in KZ. Tell the user: "Похоже, у тебя нет облака в Yandex Cloud Kazakhstan. Создай облако в https://kz.console.yandex.cloud, потом запусти меня снова." Stop.
- `cloud list` returns multiple → use the first, but tell the user one line: "Использую облако `<NAME>`. Если это не то — скажи, переключусь."
- Same logic for folder.

**Do not call `yc billing account list`.** It doesn't exist on the KZ endpoint. If the user has a cloud and a folder, billing is either active or will fail concretely at VM creation time with a clear error. Catching that one error in Step 2 is fine.

**f. SSH key.** `ls ~/.ssh/id_ed25519.pub`. If missing: silently `ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "openclaw-yc-$(date +%Y%m%d)"`.

**g. Existing instance with the same name in this cloud.** `yc compute instance get --name openclaw-bot 2>/dev/null`. If a result comes back, ask once: "У тебя уже есть VM 'openclaw-bot' — поставить новую под именем `openclaw-bot-XXXX`?" Default yes; pick a random 4-char suffix.

Do not print anything to the user about (a)-(g) if everything passed silently. Move to Step 1.

### Step 1 — Ask the two questions

In **one** message, in the user's language:

> Сейчас поставлю тебе OpenClaw-бота в Yandex Cloud. От тебя нужны две вещи (~5 минут).
>
> **1) Telegram bot token.** Открой @BotFather в Telegram → отправь `/newbot` → придумай имя (любое) → придумай username, заканчивающийся на `bot` → BotFather пришлёт токен. Пришли его мне.
>
> **2) Доступ к LLM — выбери ОДИН из трёх вариантов:**
>
>   **A) Anthropic API ключ** (рекомендую первым, лучшее качество)
>   Открой https://console.anthropic.com/settings/keys → Create Key. Ключ начинается на `sk-ant-`. На https://console.anthropic.com/settings/billing должно быть ≥$5.
>
>   **B) OpenRouter API ключ** (один ключ к Anthropic + OpenAI + 200 моделям)
>   Открой https://openrouter.ai/keys → Create Key. Начинается на `sk-or-`. Нужен баланс ≥$5 на openrouter.ai/credits.
>
>   **C) OpenAI Codex через ChatGPT подписку** (бесплатно если уже платишь Plus или Pro)
>   Не нужен ключ. Просто напиши слово **«Codex»**. После установки бота я попрошу ввести 8-символьный код на auth.openai.com — один раз.
>
> Пришли мне токен Telegram и один из трёх (ключ или слово «Codex»). Хоть в одном сообщении, хоть по отдельности. Я никуда не сохраняю и не показываю значения обратно.

**Detect the LLM provider from what the user pasted:**

```bash
case "$LLM_INPUT" in
  sk-ant-*)
    LLM_PROVIDER=anthropic ;;
  sk-or-*)
    LLM_PROVIDER=openrouter ;;
  Codex|codex|CODEX|ChatGPT|chatgpt|OpenAI*|"openai codex"|OAuth|oauth)
    LLM_PROVIDER=openai-codex
    LLM_API_KEY=""   # OAuth flow — no key at this stage
    ;;
  *)
    say "Не распознал — это Anthropic-ключ (начинается на sk-ant-), OpenRouter-ключ (sk-or-) или слово 'Codex'?" && reprompt
    ;;
esac
```

**Validate the credential** based on which option was chosen. Don't burn 15 minutes on a VM bootstrap with a bad key.

```bash
# Telegram token — always validate, confirms format + that the bot actually exists
BOT_USERNAME=$(curl -fsS "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" \
  | jq -er '.result.username') \
  || (say "Telegram токен не прошёл проверку. Скопируй его ещё раз из @BotFather." && stop)

case "$LLM_PROVIDER" in
  anthropic)
    # Minimal completion call confirms key + at least some credit
    curl -fsS https://api.anthropic.com/v1/messages \
      -H "x-api-key: ${LLM_API_KEY}" \
      -H "anthropic-version: 2023-06-01" \
      -H "content-type: application/json" \
      -d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"ok"}]}' \
      | jq -er '.content' >/dev/null \
      || (say "Anthropic ключ не работает или нет кредита. Проверь на console.anthropic.com." && stop)
    ;;
  openrouter)
    # OpenRouter exposes /api/v1/auth/key as a free credit check
    curl -fsS https://openrouter.ai/api/v1/auth/key \
      -H "Authorization: Bearer ${LLM_API_KEY}" \
      | jq -er '.data.usage != null' >/dev/null \
      || (say "OpenRouter ключ не прошёл проверку. Перепроверь на openrouter.ai/keys." && stop)
    # Optional: warn if balance below $1
    BAL=$(curl -fsS https://openrouter.ai/api/v1/auth/key -H "Authorization: Bearer ${LLM_API_KEY}" \
      | jq -r '.data.limit_remaining // 0')
    [[ "$BAL" == "0" ]] && say "На OpenRouter $0 кредита. Бот не сможет отвечать. Пополни на openrouter.ai/credits."
    ;;
  openai-codex)
    # Can't validate before OAuth — defer to Step 4.5 after VM is up.
    # Just confirm the user understands they'll need to do one extra step.
    say "Хорошо, после установки бота на VM покажу 8-символьный код для https://auth.openai.com/codex/device. Это разовое действие."
    ;;
esac
```

`BOT_USERNAME` (e.g. `your_ceo_bot`) is captured here for the one-click chat link in Step 5.

### Step 2 — Silent VM creation

**a. Ensure the default network + subnet exist in the current folder.** Brand-new folders sometimes lack them (or the user removed them). Don't assume — discover, then create if missing.

```bash
NETWORK_ID=$(yc vpc network get --name default --format json 2>/dev/null | jq -r .id 2>/dev/null)
if [[ -z "$NETWORK_ID" ]]; then
  NETWORK_ID=$(yc vpc network create --name default --format json | jq -r .id)
fi

SUBNET_ID=$(yc vpc subnet get --name default-kz1-a --format json 2>/dev/null | jq -r .id 2>/dev/null)
if [[ -z "$SUBNET_ID" ]]; then
  # Pick a /24 from 10.130.x.x that doesn't collide with the user's other subnets
  SUBNET_ID=$(yc vpc subnet create \
    --name default-kz1-a \
    --network-id "$NETWORK_ID" \
    --zone kz1-a \
    --range 10.130.0.0/24 \
    --format json | jq -r .id)
fi
```

Don't mention any of this to the user unless it errors — these are quiet idempotent ops.

**b. Render the cloud-init file.** Substitute these placeholders (no `TELEGRAM_CHAT_ID` — it's auto-detected in Step 4):

- `{{TELEGRAM_BOT_TOKEN}}` — from Step 1.
- `{{SSH_PUBLIC_KEY}}` — content of `~/.ssh/id_ed25519.pub`.
- `{{LLM_ENV_LINE}}` — depends on the chosen LLM provider:

  | Provider | Substituted line |
  |---|---|
  | anthropic | `ANTHROPIC_API_KEY=<key>` |
  | openrouter | `OPENROUTER_API_KEY=<key>` |
  | openai-codex | *(empty string — no env var at this stage; OAuth fills the profile after Step 4.5)* |

Write to `/tmp/openclaw-cloud-init.yaml` with mode 600.

**c. Create the security group:**

SSH ingress is opened to everyone (`0.0.0.0/0`) on purpose — the user and the automation AI agent that manages this box both have dynamic IPs, so per-IP locking would lock out legitimate access and is not worth maintaining. The security controls are key-only auth + fail2ban (which bans only on FAILED auths, never an authenticated key user).

```bash
SG_ID=$(yc vpc security-group create \
  --name "${VM_NAME}-sg" \
  --network-id "$NETWORK_ID" \
  --rule "direction=ingress,port=22,protocol=tcp,v4-cidrs=[0.0.0.0/0]" \
  --rule "direction=egress,from-port=0,to-port=65535,protocol=any,v4-cidrs=[0.0.0.0/0]" \
  --format json | jq -r .id)
```

Note: use `--network-id` not `--network-name`. The named lookup fails silently in some yc CLI versions when the folder has multiple networks.

**d. Create the instance.** Try `standard-v3` first (Intel Ice Lake), fall back to `standard-v2` (Cascade Lake) if v3 isn't available in this folder:

```bash
PLATFORM=standard-v3
yc compute instance create \
  --name "${VM_NAME}" \
  --zone kz1-a \
  --platform "$PLATFORM" \
  --cores 2 --memory 4 \
  --network-interface "subnet-id=${SUBNET_ID},nat-ip-version=ipv4,security-group-ids=${SG_ID}" \
  --create-boot-disk "type=network-ssd,size=30,image-folder-id=standard-images,image-family=ubuntu-2404-lts" \
  --ssh-key ~/.ssh/id_ed25519.pub \
  --metadata-from-file user-data=/tmp/openclaw-cloud-init.yaml \
  --hostname "${VM_NAME}" \
  --format json > /tmp/openclaw-vm.json 2>/tmp/openclaw-vm.err

# If v3 not available in this folder, retry with v2.
if grep -qiE "platform.*not.*found|unsupported platform" /tmp/openclaw-vm.err; then
  yc compute instance create --platform standard-v2 ...   # same flags
fi
```

Capture `external_ipv4_address` from `/tmp/openclaw-vm.json` (`.network_interfaces[0].primary_v4_address.one_to_one_nat.address`). Then one sentence to the user:

> VM создана. Ставлю OpenClaw — займёт около 10 минут. Можешь пока заварить чай.

If the instance create errors with `billing account is not active` or `billing_disabled` — that's the one billing case we couldn't detect in Step 0 (because there's no `yc billing` on KZ). Tell the user: "Похоже, в Yandex Cloud Kazakhstan не активирован биллинг. Открой https://kz.console.yandex.cloud/billing — там подсказка. Когда активируешь, запусти меня снова — VM подхвачу автоматически." Stop.

### Step 3 — Wait silently for cloud-init

Poll every 30 seconds:

```bash
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 openclaw@$IP \
  'test -f /var/lib/openclaw-bootstrap-done && echo READY || echo PENDING'
```

Don't spam the user with raw log lines. Every ~3 minutes, emit one warm progress sentence based on the current phase you see in `tail -1 /var/log/openclaw-bootstrap.log`:

| If the log mentions | Tell the user |
|---|---|
| firewall / hardening | "Настраиваю файрвол и SSH" |
| nodesource / Node | "Ставлю Node.js" |
| `npm install` openclaw | "Качаю OpenClaw" |
| ceo-ai-os / install.sh | "Загружаю CEO-скиллы" |
| openclaw onboard / config | "Подключаю Telegram и LLM" |
| systemd / health | "Запускаю бот" |

Cap at 15 minutes. If still not ready, surface to the user with a single sentence ("Что-то пошло не так на VM, проверяю логи") and jump to `references/04-troubleshooting.md`.

### Step 3.5 — OAuth device-code flow (ONLY if `LLM_PROVIDER=openai-codex`)

Skip this entire step for `anthropic` and `openrouter` — they have the key already in `gateway.env` and the bot is ready to talk.

The provider id is **`openai-codex`** and the command **`openclaw models auth login --provider openai-codex --device-code`** is real and current (verified against OpenClaw `docs/providers/openai.md` — the device-code path exists specifically for headless/callback-hostile VMs; OpenClaw performs the OAuth itself and stores the profile as `openai-codex:default`). Do **not** rename the provider to `codex` (that is the agent-runtime id, a different concept) or shell out to a native `codex login` binary (not needed).

For `openai-codex`, the cloud-init left the gateway running but without a model configured (no Anthropic/OpenRouter key was in env). First make sure the Codex provider is loadable, then run the device-code OAuth flow now via SSH with a forced TTY:

```bash
# The openai-codex auth provider ships in the bundled `openai` extension, but on
# some builds the login errors with "No provider plugins found" until the Codex
# plugin is present. Ensure it once (idempotent, harmless if already there):
ssh openclaw@$IP "openclaw plugins install clawhub:@openclaw/codex 2>/dev/null || true"

ssh -tt -o ServerAliveInterval=30 openclaw@$IP \
  "openclaw models auth login --provider openai-codex --device-code" \
  | tee /tmp/openclaw-oauth.log
```

The CLI prints two things on stdout, usually within 2 seconds:

- A URL like `https://auth.openai.com/codex/device` (or `https://auth.openai.com/device`)
- An 8-character code like `ABCD-1234`

Extract both with a regex on `/tmp/openclaw-oauth.log` (the formats may shift slightly across OpenClaw releases — match generously). Show the user **one** clean message:

> Последний шаг — подключи бота к ChatGPT.
>
> Открой в браузере: **https://auth.openai.com/codex/device**
>
> Введи код: **ABCD-1234**
>
> Войди под аккаунтом ChatGPT (Plus или Pro) и разреши доступ. Жду до 15 минут.

The SSH session will block until the user completes the device flow (or the 15-minute server-side timeout expires). When it exits 0, OpenClaw has written `auth-profiles.json` with the OAuth token and the gateway will pick it up on next config reload.

**Known pitfall (issue #74212, 2026-05):** in some SSH sessions OpenClaw masks the device-pairing code as `[shown on the local device only]`. If that's what you see in `/tmp/openclaw-oauth.log`:

- Retry with `ssh -tt` if you didn't already — the masking is triggered by a TTY check that some non-interactive SSH invocations fail.
- If retry doesn't help: run the auth command directly inside a fresh SSH session (`ssh openclaw@$IP` → `openclaw models auth login --provider openai-codex --device-code`), pull the code from there, then resume the wizard.

After the profile is written, probe the subscription tier and set the model. **Two things verified on a live OpenClaw 2026.5.27 bot:** Codex subscription models are registered in the **`openai/` namespace** (e.g. `openai/gpt-5.5`), *not* `openai-codex/*` — the `openai-codex` profile only backs the auth, so `openclaw models list --provider openai-codex` returns **nothing**; and the JSON flag for `models list` is **`--json`**, not `--format json`. So:

```bash
HAS_GPT55=$(ssh openclaw@$IP "openclaw models list --json 2>/dev/null" \
  | jq -r '.[]?.id // empty' 2>/dev/null | grep -Fx 'openai/gpt-5.5' || true)
if [[ -n "$HAS_GPT55" ]]; then
  # Pro tier — gpt-5.5 available
  ssh openclaw@$IP "openclaw config set agents.defaults.model.primary 'openai/gpt-5.5' && openclaw config set agents.defaults.model.fallbacks '[\"openai/gpt-5.4\"]'"
else
  # Plus tier (or gpt-5.5 not granted) — use the gpt-5.4 family
  ssh openclaw@$IP "openclaw config set agents.defaults.model.primary 'openai/gpt-5.4' && openclaw config set agents.defaults.model.fallbacks '[\"openai/gpt-5.4-mini\"]'"
fi
ssh openclaw@$IP "sudo systemctl restart openclaw-gateway"
```

If after 15 minutes the SSH session timed out and `auth-profiles.json` still has no `openai-codex` profile: tell the user "не получилось войти в ChatGPT, давай попробуем ещё раз" and re-run the SSH `openclaw models auth login` command. Don't kill the VM — only the OAuth step needs to be retried.

### Step 4 — One-click chat link + auto-pair (the actual flow, not the easy one)

When `/var/lib/openclaw-bootstrap-done` exists, the gateway is up in `pairing` mode. **This is where wizards historically fail silently**: they think `openclaw pairing approve` succeeded, hand off to the user, and the user discovers the bot still asks for a confirmation code. Read this whole section.

#### What actually happens when the user presses /start

1. Telegram delivers `/start` to the bot.
2. OpenClaw enforces `dmPolicy=pairing` — it does **not** drop the message, it calls `issuePairingChallenge()`, records a pending request in the gateway, **and the bot replies to the user with the pairing code + instructions** (something like "To complete pairing, ask your admin to run `openclaw pairing approve telegram ABCD-1234`"). The user sees this message; they shouldn't reply to it.
3. The pending request lives in `openclaw pairing list telegram --format json` until either approved or expired.

#### Pre-emptive heads-up to the user

Send this **before** asking them to press /start, so they don't get confused when the bot's first reply is a code message:

> Бот готов. Открой его в Telegram: **https://t.me/{{BOT_USERNAME}}** и нажми `/start`.
>
> Бот пришлёт тебе короткое сообщение с кодом — **ничего с ним делать не надо, я подтвержу доступ автоматически за пару секунд**. После этого можешь начать переписываться с ботом как обычно.

#### Poll for chat_id (Telegram getUpdates)

```bash
# Empty getUpdates returns until the user actually presses /start
for i in $(seq 1 150); do  # 150 × 2s = 5 minutes
  CHAT_ID=$(curl -fsS "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?timeout=2" \
    | jq -r '.result[0].message.chat.id // empty')
  [[ -n "$CHAT_ID" ]] && break
  sleep 2
done
[[ -z "$CHAT_ID" ]] && fail "Пользователь не нажал /start за 5 минут"
```

#### Approve the pairing (the part the old wizard broke)

The pending pairing request lives in `openclaw pairing list telegram --format json`. Its schema (verified against `src/gateway/protocol/schema/devices.ts`) uses **`senderId`** for the Telegram user ID and **`requestId`** for the primary key — **not** `chatId` and **not** `code`. A wizard that filters on `.chatId == $CHAT_ID` always gets empty results and the subsequent `xargs approve` is a no-op.

Defensive filter (handles both schemas in case a future OpenClaw release renames again):

```bash
# Give the gateway up to 10s to record the pending request after /start arrives
APPROVE_TOKEN=""
for i in $(seq 1 5); do
  APPROVE_TOKEN=$(ssh openclaw@$IP "openclaw pairing list telegram --format json 2>/dev/null" \
    | jq -r --arg cid "$CHAT_ID" '
        .[]
        | select(
            (.senderId|tostring) == $cid
            or (.chatId|tostring) == $cid
            or (.sender // empty | tostring) == $cid
          )
        | (.requestId // .code // empty)
      ' \
    | head -n1)
  [[ -n "$APPROVE_TOKEN" ]] && break
  sleep 2
done

[[ -z "$APPROVE_TOKEN" ]] && fail "Запрос на pairing не появился в openclaw pairing list — посмотри journalctl на VM"

ssh openclaw@$IP "openclaw pairing approve telegram '$APPROVE_TOKEN'" \
  || fail "openclaw pairing approve failed (token=$APPROVE_TOKEN)"
```

#### Lock down + persist chat_id

```bash
ssh openclaw@$IP "
  openclaw config set channels.telegram.dmPolicy allowlist
  openclaw config set channels.telegram.allowFrom '[${CHAT_ID}]'
  echo 'TELEGRAM_CHAT_ID=${CHAT_ID}' >> /home/openclaw/.openclaw/gateway.env
  sudo systemctl restart openclaw-gateway
"
```

Wait ~60s for the gateway to come back. Don't proceed to Step 5 until `/health` returns 200:

```bash
for i in $(seq 1 30); do
  ssh openclaw@$IP 'curl -fsS -m 3 http://127.0.0.1:18789/health' >/dev/null 2>&1 && break
  sleep 2
done
```

If the user doesn't press `/start` within 5 minutes, poke them gently with a one-line reminder. After 15 minutes of no signal, stop and tell them how to resume.

### Step 5 — Verify the bot answers (do NOT skip), then hand off

The wizard's #1 historical failure mode was claiming "done" while the bot was still silent. The verification below is **mandatory** — three independent signals, all must pass, before you tell the user it works.

#### 5a. Trigger a reply

Send a probe message from the user's laptop, in the language set in Step 0 (`USER_LANGUAGE`):

```bash
# Language-matched probe text
case "$USER_LANGUAGE" in
  ru) PROBE="Скажи 'привет', чтобы я убедился, что ты отвечаешь." ;;
  kk) PROBE="Сәлем деп жаз — жауап беретіндігіңе көз жеткізейін." ;;
  *)  PROBE="Say 'hi' so I can confirm you're alive." ;;
esac

curl -fsS "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
  -d "chat_id=${CHAT_ID}" \
  -d "text=${PROBE}"
```

#### 5b. Three checks, all required

**Check 1 — Gateway logged an outgoing message.** Watch the journal for an outbound telegram event within 90 seconds:

```bash
ssh openclaw@$IP "
  timeout 90 sudo journalctl -u openclaw-gateway -f --no-pager 2>/dev/null \
    | grep -m1 -E 'telegram.*sent|outgoing.*telegram|sendMessage.*ok'
"
```

If this times out: gateway accepted the inbound but didn't reply. Most likely cause = LLM provider not configured (Codex OAuth didn't finish, or env key empty). Jump to `references/04-troubleshooting.md` §4c-e.

**Check 2 — A bot reply appears in `getUpdates`** within 90s. Poll for any new update authored by the bot:

```bash
LAST_OFFSET=$(curl -fsS "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?offset=-1" \
  | jq -r '.result[-1].update_id // 0')

for i in $(seq 1 45); do
  REPLY=$(curl -fsS "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?offset=$((LAST_OFFSET+1))&timeout=2" \
    | jq -r '.result[]? | select(.message.from.is_bot==true) | .message.text' \
    | head -n1)
  [[ -n "$REPLY" ]] && break
  sleep 2
done

[[ -z "$REPLY" ]] && fail "Не вижу ответа бота в getUpdates через 90с"
```

Note: a bot's own outgoing messages don't appear in `getU

…(truncated)
