@pierre-gilles, I created a separate topic to avoid cluttering your post about external integrations.
I gave everything to Claude, and here is his analysis regarding Free Mobile:
1. Context
Gladys is getting external integrations: programs running in isolated Docker containers, supervised
by Gladys, communicating with it via a host API (REST) + an outgoing WebSocket, wrapped by
@gladysassistant/integration-sdk. Two types of manifest exist today (manifest.schema.json, l.16-19) :
device— exposes devices via the Devices / Discovery / Configuration screens.communication— messaging channels (bots like Telegram): no device screen, the user links their
account from the Gladys UI, and the integration exchanges messages via the host API.
I am porting the historic Free Mobile SMS service, previously integrated into the core of Gladys, to an external
integration. Free Mobile is a communication channel, so type: "communication" is the natural choice — but this has
revealed a structural gap in the current communication model. This document explains this gap, proves it against
the code, and proposes a minimal and backward-compatible fix.
2. How the Communication Model Works Today
A communication integration only talks to Gladys about linked contacts. Gladys never says « send to
William »; it says « send to contact X », where X is an identifier that the integration itself created
when linking. Three building blocks (README integration-sdk + host API controller) :
- Linking (consent) — the user clicks « Link my account » in the Gladys UI, which generates a code
(single-use, TTL 15 min). The user sends this code to the bot in the external channel; the integration receives it and callslinkContact(code, contactId)→POST /api/integration/v1/contact/link. - Incoming —
publishMessage(contactId, text)→POST /api/integration/v1/message. An unknown (unlinked) contact triggers a 404. - Outgoing —
onSendMessage(contactId, message): responses from the brain and notifications transferred, delivered by
the integration in the external channel.
The Code Exists to Prove an Incoming Path
The whole purpose of the code is that it transits through the external channel: by sending it back to the bot, the person
in Telegram/Signal proves they are the Gladys user who generated it. This is confirmed by the code
itself :
externalIntegration.createLinkCode.js— comment: « The user then sends it to the bot in the external
channel, and the integration calls POST /contact/link with it. » Alphabet chosen to be « typed in a chat ».externalIntegration.linkContact.js— the code must be present in the cache (created by the UI) and not expired,
otherwiseNotFoundError('INVALID_LINK_CODE'). There is no other way to create a contact. No self-linking path exists.
The model therefore assumes a bidirectional channel.
3. The Gap: Outbound-Only Channels
An entire class of communication integrations has no incoming channel — they can only push a
notification :
- Free Mobile SMS (sending an SMS to one’s own number via a webhook),
- Pushover, ntfy, Gotify,
- incoming Discord / Slack webhooks,
- SMTP email.
For all of them, two things are simultaneously true :
- The code cannot transit. There is nowhere to send it — no bot, no incoming endpoint. The central step of the core’s linking flow is physically impossible.
- There is no identity to prove by round-trip. The user enters their own destination — their API key, their webhook URL, their email address — in their own Gladys config page, while they are already authenticated in Gladys. Consent is the act of filling out and saving this field. A code proving « it’s really you in the external channel » answers a question no one is asking.
What the User Actually Experiences Today (Free Mobile)
The communication config page (PR #2665, config-page/ConfigTab.jsx l.95) displays LinkAccountCard without
condition for any communication integration. The Free Mobile user therefore sees :
- The native « Link my account » card with a « Generate a code » button → displays for example
ABCD2345, and
(its i18n text) asks to « send this code to the bot in the external channel » — factually false: Free
Mobile does not have such a channel. - The only workaround compatible with the current core is a manifest action (« Link my account ») with a code field: the user copies
ABCD2345from card #1 and pastes it into the action a few centimeters below, which callslinkContact(code, username).
This is an empty ceremony: the user copies a code from one card and pastes it into another, to prove a path that does not exist. It is confusing and looks broken. This is the problem to fix.
4. Proposal: an Optional messaging_mode Manifest Field
Add an optional top-level manifest field, only making sense when type: "communication" :
{
"type": "communication",
"messaging_mode": "outbound", // "bidirectional" (default) | "outbound"
}
```- **`"bidirectional"` (default)** — the current behavior, **unchanged**. Telegram / Signal / Matrix bots declare nothing and continue to work exactly as before. Fully backward compatible.
- **`"outbound"`** — the integration only _delivers_ messages, it never receives any. The core then:
1. **hides the native `LinkAccountCard`** — the code flow doesn't make sense here;
2. exposes an endpoint of the host API to **link the config owner without code**
(`POST /api/integration/v1/contact/self-link { contact_id }`) : it links the **authenticated user of the
config** to `contact_id`. Consent = the user has filled out and saved their own destination in their
own config page;
3. everything downstream is unchanged — `onSendMessage(contactId, message)` triggers normally;
`publishMessage` / incoming routing simply never happen.
### Why This Is the Right Split
- **Minimal & backward compatible.** One optional enum field. The bidirectional contract (B.15) is intact; the code flow files (`createLinkCode.js`, `linkContact.js`) are not modified — the outbound is a **parallel path**, not a rewrite.
- **Solves the whole family**, not just Free Mobile (Pushover, ntfy, webhooks, SMTP…).
- **Honest UX.** No misleading instruction “send this code to the bot”, no copy-paste ceremony. Free Mobile's UX reduces to _fill identifier + API key → save → done_, the historical behavior built into the core.
- **Security is not weakened.** `self-link` only links the **already authenticated** user of the config page to a destination **they entered themselves**. No cross-user linking, no privilege escalation. It's exactly the consent that the code flow provides (“this Gladys user is okay”), minus the round-trip that only makes sense for a bidirectional channel. (An outbound integration also can't impersonate another user: `self-link` is scoped to the config `req` owner, and there is no incoming path to receive responses with someone else's authority.)
---
## 5. Concrete Changes (Anchor Points in PR #2665)
| # | Layer | File (PR #2665) | Change |
| --- | --------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Manifest schema | `server/lib/external-integration/manifest.schema.json` | add an optional enum `messaging_mode` `["bidirectional","outbound"]`, default `"bidirectional"`. The same file is used by the store indexer, so outbound manifests validate everywhere. |
| 2 | Core — lib link | `server/lib/external-integration/externalIntegration.selfLinkContact.js` _(new)_ | link the config owner user to `contact_id` **without code**; reuse the existing `CONTACT_VARIABLE` storage (same shape as `linkContact`, without the code lookup). |
| 3 | Core — host API | `server/api/controllers/integrationHost.controller.js` + `server/api/routes.js` | new route `post /api/integration/v1/contact/self-link` + controller method, alongside the existing `contact/link` (`routes.js` groups the `integration/v1/*` routes together). |
| 4 | Core — UI | `front/.../config-page/ConfigTab.jsx` (l.95) | only show `LinkAccountCard` when `messaging_mode !== 'outbound'` (read from `integration.manifest`). |
| 5 | SDK | `@gladysassistant/integration-sdk` (`lib/gladys-integration.js`, `index.d.ts`, README) | add `selfLinkContact(contactId)` (POST `/contact/self-link`) alongside `linkContact`; document `messaging_mode`. |
Nothing else changes. `onSendMessage`, `getContacts`, `unlinkContact`, the container state machine, the WebSocket protocol — all intact.
### Draft of the New Core Lib (Based on `linkContact.js`)
```js
// externalIntegration.selfLinkContact.js
const { CONTACT_VARIABLE } = require('./constants');
const { BadParameters } = require('../../utils/coreErrors');
/**
* @description Link the config-owner user to an external contact WITHOUT a code
* (outbound-only communication integrations): consent is the user filling their
* own destination in their own config page. Stored like linkContact.
* @param {object} service - The external integration service.
* @param {string} userId - Id of the authenticated config-owner user.
* @param {object} body - { contact_id, contact_name? }.
* @returns {Promise<object>} { user: { selector, first_name, language } }.
*/
async function selfLinkContact(
service,
userId,
{ contact_id: contactId, contact_name: contactName } = {},
) {
if (typeof contactId !== 'string' || contactId.length === 0) {
throw new BadParameters('contact_id: must be a non-empty string');
}
// (guarded by the host API: service.manifest.messaging_mode must be 'outbound')
await this.variable.setValue(
CONTACT_VARIABLE,
JSON.stringify({
contact_id: contactId,
contact_name: contactName || null,
linked_at: new Date().toISOString(),
}),
service.id,
userId,
);
// return the user, like linkContact
}
6. Open Questions for the Maintainer
- Endpoint shape —
POST /contact/self-link { contact_id }guarded bymessaging_mode === 'outbound', vs.
auto-linking on first config save (no explicit SDK call). The explicit call is more predictable and lets the integration choose thecontact_id; the implicit one requires even less code from the integration. Preference? - Naming —
messaging_mode: "outbound"vs. a booleaninbound: falsevs. a list of capabilities.
messaging_modereads the best if a future third mode (inbound-only?) ever appears.


