# Trigger Agent Mode flag on Button Reply buttons

**Date:** 2026-07-31
**Status:** Design approved, pending implementation plan

## Goal

Let a journey author mark **one** button on a Button Reply node as "Trigger Agent
Mode", and persist that intent through every layer: the raw flowchart, the journey
save payload, the compiled channel template's `setting_json`, and a queryable column
on `channel_templates`.

## Scope: flag only

There is no "agent mode" anywhere in the codebase today. This feature adds **no
runtime behavior** — no send-path change, no webhook change, no automation
suppression, no chat-inbox indicator. Tapping a flagged button behaves exactly as it
does now. The flag is stored so a later phase (or an external consumer) can act on it.

The closest existing capability is the manual reply composer in the chat inbox
(`RecipientChatController::reply`, commit d9e88ad). Wiring the flag to it is
explicitly out of scope.

## Non-goals

- **Runtime effect of any kind.** See above.
- **Blast editor.** `resources/js/flow/blast.jsx:2282` has its own duplicate
  `ButtonReplyNode`; it is not modified, and `seedTemplateBlast()` is not touched.
- **List Reply node / other node types.** Button Reply only.
- **More than one flagged button per node.** At most one, enforced in the UI.

## Data shape

Because at most one button per node carries the flag, everything downstream of the
node collapses to a single scalar — the flagged button's id. This is what keeps the
change small: the shared `toButtonPayload()` helper (`HelperTrait.php:175`, used by
~40 seeders) needs no per-button field.

| Layer | Key | Value |
|---|---|---|
| Node data — raw flowchart (`CustomJourney.payload`, version snapshots) | `data.button_reply[i].triggerAgentMode` | `true` on at most one button |
| Journey save payload (`templates[].settings[<lang>]`) | `agentModeButtonId` | flagged button's id, or `null` |
| `channel_templates.setting_json[<lang>]` | `agentModeButtonId` | same scalar |
| `channel_templates` | `agent_mode_button_id` | new nullable column |

The source of truth is the per-button boolean in node data — that is what the checkbox
binds to. It rides in the raw flowchart, so editor reload and version restore require
no additional work.

Naming follows the existing `imageUrl` precedent: camelCase in JSON at every layer,
snake_case only for the database column.

## Changes

### 1. `resources/js/flow/flow.jsx`

**ButtonReplyNode** (`:1471`)

Add a checkbox inside each button's box (`:2225`–`:2298`), below the per-language
title inputs and above the "Delete Button" button:

```
┌─ Button 1 ──────────────────┐
│ English:                    │
│ [ Talk to agent          ]  │
│ [x] Trigger Agent Mode      │
│ [ Delete Button ]           │
└─────────────────────────────┘
```

Add `onAgentModeChange(buttonId, checked)`, following the same `setButtons` +
`setNodes` pattern as `onButtonTitleChange` (`:1574`). It rewrites the whole button
list:

```js
triggerAgentMode: checked && button.id === buttonId
```

so ticking Button 2 unticks Button 1, and unticking clears the flag entirely. At most
one flagged button per node, enforced here and nowhere else.

No new handles, no edge changes, no interaction with the Business Initiated checkbox.
`deleteButtonFromList` (`:1686`) already renumbers button ids and the flag travels on
the button object, so renumbering needs no change.

**Serialization** (`:10222`)

In the button-reply non-language-specific block, alongside `isBusinessInitiated` and
`imageUrl`:

```js
settings[primaryLang].agentModeButtonId =
    data.button_reply?.find((b) => b.triggerAgentMode)?.id ?? null;
```

The scalar stays on a single locale block on the JS side — the PHP compile copies it
into every locale (see below), exactly as it already does for `imageUrl`. So the
"duplicate to other included languages" block (`:10514`) needs **no** change.

One place does need it: when `primaryLang` is not among the included locales, the
serializer moves non-language fields onto `targetLang` and deletes them from
`primaryLang` (`:10438`), and fully-empty locale blocks are dropped entirely
(`:10551`). Add `agentModeButtonId` to that move alongside `isBusinessInitiated`, or a
node with no English content would write the flag onto a block that is then deleted.

### 2. `app/Http/Controllers/Journey/JourneyController.php`

**`seedTemplate()`** button-reply branch (`:3082`)

Read the scalar once from whichever locale block carries it — not hardcoded to `en`,
because the editor drops fully-empty locale blocks:

```php
$agentModeButtonId = collect($component['settings'])
    ->pluck('agentModeButtonId')
    ->first(fn ($id) => $id !== null);
```

When it is not null, set
`$text[$key]['agentModeButtonId']` for every locale — `setting_json` is reduced to a
single locale at send time, so a value present on only one locale would be lost. Guard
with `!== null`, mirroring the existing `$imageUrl !== ''` guard at `:3098`.

Also add `'agent_mode_button_id' => $agentModeButtonId` to the `$templates->add([...])`
array (`:3103`) — that array is what `import()` fills the model from, so this is how
the column gets its value.

When the flag is cleared, the key is simply absent from the rebuilt `setting_json`,
which `import()` assigns wholesale (`HasTemplateImportTrait.php:38`) — so removal
needs no special handling in `setting_json`. The column does; see below.

### 3. Migration + model

- New migration `add_agent_mode_button_id_to_channel_templates`: nullable
  `unsignedTinyInteger`. Null means no button on this template triggers agent mode.
- `app/Models/ChannelTemplates.php`: add `agent_mode_button_id` to `$fillable`.

### 4. `database/seeders/Traits/HasTemplateImportTrait.php`

The gotcha. `import()` (`:19`) constructs a new `ChannelTemplates` from the full
template array for a new row, but for an **existing** row it copies only
`setting_json` (`:38`). Without an explicit assignment the new column goes stale and
unchecking the box would never clear it. In the existing-row branch:

```php
$channel_template->agent_mode_button_id = $template['agent_mode_button_id'] ?? null;
```

The `?? null` keeps the ~40 seeders that never set the key at `null`, which is correct
for them.

## Testing

There is no JS test runner in this repo (`package.json` has only Laravel Mix
scripts), and the serialization/compile steps are single assignments. The one branch
that can break silently is the `import()` clear-on-uncheck path.

One feature test:

- Import a button-reply template with `agent_mode_button_id = 2`; assert the column
  and `setting_json[<lang>]['agentModeButtonId']` both hold `2`.
- Re-import the same slug with the flag absent; assert the column is back to `null`.

`phpunit.xml` sets `APP_ENV=testing`, so Laravel loads `.env.testing`
(`DB_DATABASE="db_test"`) and `RefreshDatabase` wipes only that database, not the shared
dev `db`. Verify that before the first run. PHP runs inside DDEV
(`ddev exec vendor/bin/phpunit`) — `db` is not reachable from the host shell.

The checkbox's mutual exclusion is verified by hand in the editor: tick Button 1, tick
Button 2, confirm Button 1 clears; save, reload, confirm the tick persists.

## Backward compatibility

Every field is additive and nullable. A node with no flagged button serializes
`agentModeButtonId: null`, writes `null` to the new column, and produces
`setting_json` identical to today apart from that one key. Existing templates,
seeders, and the blast path are unaffected.
