Skip to content

Commit 86bfa20

Browse files
feat(gateway): add platform integration gateway with TUI support
Implement external platform integration (Feishu, DingTalk, Telegram) via declarative YAML manifests and conversational configuration flow. Gateway module: - PlatformAdapter Protocol + PlatformAdapterMixin for graceful degradation - GatewayServer: adapter lifecycle, message routing, session tracking - ManifestLoader: declarative YAML platform discovery - CredentialVault: Fernet-encrypted at-rest credential storage - SessionKey: structured, immutable session routing - GatewayConfigStore: atomic writes, env-var overrides - Validators: credential pre-flight checks (Feishu, DingTalk, Telegram) - EventBus integration: GatewayMessageReceived, SessionCreated/Ended Security (six-layer defense): - Credentials never appear in LLM context or tool results - Expanded redaction patterns for gateway tokens in logs - File-read blocking for gateway.yaml and .credential_key - Validator URL logging suppression (aiohttp trace_configs) TUI integration: - /gateway slash command: connected/configured/available status panel - /status enhanced with gateway connection info - Startup banner shows gateway-connected platforms - Tab completion and /help include gateway commands Config-as-Conversation UX: - gateway_connect tool with guide/connect/disconnect/remove/status - Manifest-driven setup guides with prompt hints for 1-turn setup - Environment variable overrides for CI/CD deployments README updated with Gateway section, custom adapter guide, and env docs. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 467de81 commit 86bfa20

27 files changed

Lines changed: 2333 additions & 14 deletions

‎README.md‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,96 @@ All output flows through `LeapConsole`, ensuring consistent theming. All input f
428428

429429
---
430430

431+
## External Platform Integration (Gateway)
432+
433+
LeapFlow can connect to external messaging platforms — **Feishu (Lark)**, **DingTalk**, **Telegram**, and more — turning any IM channel into a natural-language interface to the agent. Platforms are integrated through a declarative **manifest** system and configured via a conversational setup flow, with no source-code changes required.
434+
435+
### Design Highlights
436+
437+
| Aspect | Approach |
438+
|--------|----------|
439+
| **Config-as-Conversation** | Say *"connect to Feishu"* in the REPL. The agent walks you through credential setup in 1–2 turns — no config files to edit by hand. |
440+
| **Declarative Manifests** | Each platform is defined by a YAML manifest (credentials, setup guide, adapter module). Add a new platform by dropping a `.yaml` file. |
441+
| **Credential Security** | Secrets are encrypted at rest (Fernet AES-128-CBC), never appear in LLM context or logs, and can be overridden via environment variables (`LEAPFLOW_<PLATFORM>_<KEY>`). |
442+
| **Lazy Loading** | Platform SDK dependencies are imported only when a platform is first connected, keeping CLI startup instant. |
443+
| **Adapter Protocol** | Platform adapters implement a simple Python `Protocol` — `connect()`, `disconnect()`, `send()`, `on_message` callback — extensible via `PlatformAdapterMixin` for graceful degradation. |
444+
445+
### Quick Start
446+
447+
```
448+
❯ connect to Telegram
449+
# Agent shows setup steps, asks for Bot Token
450+
❯ <paste your bot token>
451+
# Agent validates, encrypts, connects — done.
452+
```
453+
454+
Or use the `/gateway` slash command to check connection status:
455+
456+
```
457+
❯ /gateway
458+
┌── Gateway ─────────────────────┐
459+
│ Connected │
460+
│ ● Telegram (5m 32s) │
461+
│ Available │
462+
│ 飞书, DingTalk, Webhook │
463+
│ │
464+
│ Say "connect to <platform>" │
465+
│ to set up a new integration. │
466+
└────────────────────────────────┘
467+
```
468+
469+
### Adding a Custom Platform
470+
471+
1. Create a YAML manifest in `~/.leapflow/profiles/<profile>/gateway/manifests/`:
472+
473+
```yaml
474+
platform_id: my_platform
475+
display_name: My Platform
476+
category: im
477+
478+
credentials:
479+
- key: api_key
480+
label: API Key
481+
required: true
482+
secret: true
483+
484+
setup_guide:
485+
summary_en: "Provide your API key to connect."
486+
487+
adapter:
488+
module: my_package.adapter
489+
class: MyAdapter
490+
dependencies: [my-sdk]
491+
```
492+
493+
2. Implement the adapter:
494+
495+
```python
496+
from leapflow.gateway.protocol import PlatformAdapter
497+
498+
class MyAdapter:
499+
def __init__(self, api_key: str, **kwargs): ...
500+
async def connect(self, *, is_reconnect: bool = False) -> None: ...
501+
async def send(self, target, content) -> SendResult: ...
502+
async def disconnect(self) -> None: ...
503+
```
504+
505+
3. Say *"connect to my_platform"* in the REPL — the agent handles the rest.
506+
507+
### Environment Variable Overrides
508+
509+
For deployment environments (CI/CD, containers), set credentials as environment variables instead of interactive configuration:
510+
511+
```bash
512+
export LEAPFLOW_TELEGRAM_BOT_TOKEN=your_token_here
513+
export LEAPFLOW_FEISHU_APP_ID=cli_xxx
514+
export LEAPFLOW_FEISHU_APP_SECRET=xxx
515+
```
516+
517+
Environment variables take precedence over values stored in `gateway.yaml`.
518+
519+
---
520+
431521
## Workflow Copilot (Preview)
432522

433523
LeapFlow includes a **Workflow Copilot** that predicts your next action and offers proactive suggestions — like GitHub Copilot, but for any workflow on your computer.
@@ -641,6 +731,7 @@ uv run pytest -k "test_world_model" -q # By keyword
641731
| `engine/` | Session orchestration and ReAct execution loop |
642732
| `memory/` | Three-tier event-driven memory (working → episodic → long-term) |
643733
| `platform/` | Platform adaptation layer — protocol abstraction for pluggable execution backends |
734+
| `gateway/` | External platform integration — declarative manifests, credential vault, adapter lifecycle |
644735
| `hub/` | Multi-source skill hub (ModelScope, GitHub, local) |
645736

646737
<details>
@@ -665,6 +756,7 @@ uv run pytest -k "test_world_model" -q # By keyword
665756
| Tools | `src/leapflow/tools/` | registry, builtins | Built-in tool definitions for the ReAct loop |
666757
| CLI | `src/leapflow/cli/` | cli, commands/, banner | Argument parsing, subcommand dispatch, interactive REPL |
667758
| Storage | `src/leapflow/storage/` | duckdb, skill_library | DuckDB-backed persistent storage for skills, trajectories, audit |
759+
| Gateway | `src/leapflow/gateway/` | server, manifest, protocol, credential_vault | External platform integration — manifest discovery, adapter lifecycle, credential encryption |
668760
| Execution Backend | external (pluggable) | — | OS interaction: screen capture, AX tree, input injection (default: `cua-driver` via MCP) |
669761

670762
</details>
@@ -678,6 +770,7 @@ uv run pytest -k "test_world_model" -q # By keyword
678770
| `PredictorLayer` | `copilot/types.py` | Prediction algorithm interface (predict, on_feedback, priority, timeout) |
679771
| `SignalChannel` | `copilot/types.py` | Dynamically registerable signal source (start, stop, subscribe) |
680772
| `HintRenderer` | `copilot/types.py` | Ghost-hint display abstraction (show, dismiss, is_visible) |
773+
| `PlatformAdapter` | `gateway/protocol.py` | External platform adapter contract (connect, disconnect, send, on_message) |
681774

682775
**Core Data Types:**
683776

‎src/leapflow/cli/banner.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"hub_push": "hub",
3939
"hub_pull": "hub",
4040
"hub_search": "hub",
41+
"gateway_connect": "gateway",
4142
}
4243

4344

@@ -87,6 +88,7 @@ def display_rich_banner(
8788
skills: Optional[Sequence[Any]] = None,
8889
context_length: int = 0,
8990
mcp_tools: int = 0,
91+
gateway_connected: Sequence[str] = (),
9092
show_welcome: bool = True,
9193
) -> None:
9294
"""Print the full-width Rich banner panel with tools/skills catalog."""
@@ -160,6 +162,14 @@ def display_rich_banner(
160162
names_str = names_str[:49] + "…"
161163
right_lines.append(f"[{_DIM_GOLD}]{cat}:[/] [{_CREAM}]{names_str}[/]")
162164

165+
if gateway_connected:
166+
right_lines.append("")
167+
right_lines.append(f"[bold {_AMBER}]Gateway[/]")
168+
names_str = ", ".join(gateway_connected)
169+
if len(names_str) > 52:
170+
names_str = names_str[:49] + "…"
171+
right_lines.append(f"[green]●[/] [{_CREAM}]{names_str}[/]")
172+
163173
if not tool_defs and not skills:
164174
right_lines.append(f"[{_WARM_GRAY}]No tools or skills loaded[/]")
165175

@@ -173,6 +183,8 @@ def display_rich_banner(
173183
summary_parts.append(f"{skill_count} skills")
174184
if mcp_tools:
175185
summary_parts.append(f"{mcp_tools} mcp")
186+
if gateway_connected:
187+
summary_parts.append(f"{len(gateway_connected)} gateway")
176188
summary_parts.append("/help for commands")
177189
right_lines.append("")
178190
right_lines.append(f"[{_WARM_GRAY}]{' · '.join(summary_parts)}[/]")

‎src/leapflow/cli/commands/interactive.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ async def cmd_interactive(ctx: "Context") -> int:
4040
handle_usage,
4141
handle_model,
4242
handle_clear,
43+
handle_gateway,
4344
)
4445
from leapflow.utils.terminal_io import TerminalIOProvider
4546
from leapflow.engine.session import SessionMode
@@ -131,6 +132,18 @@ def _update_status() -> None:
131132
else ctx.settings.llm_context_length
132133
)
133134

135+
def _gateway_connected_names() -> list[str]:
136+
gw = getattr(ctx, "gateway_server", None)
137+
if gw is None:
138+
return []
139+
return [
140+
(gw.manifests[s.platform_id].display_name
141+
if s.platform_id in gw.manifests
142+
else s.platform_id)
143+
for s in gw.platform_status()
144+
if s.connected
145+
]
146+
134147
def _render_banner() -> None:
135148
display_rich_banner(
136149
model=ctx.settings.llm_model,
@@ -141,6 +154,7 @@ def _render_banner() -> None:
141154
skills=all_skills,
142155
context_length=ctx_len,
143156
mcp_tools=mcp_count,
157+
gateway_connected=_gateway_connected_names(),
144158
)
145159

146160
# ── Stream response ──────────────────────────────────────────────
@@ -246,6 +260,10 @@ async def handle_input(text: str) -> None:
246260
handle_tools(ctx, console, cmd_args)
247261
return
248262

263+
if canonical == "gateway":
264+
handle_gateway(ctx, console, cmd_args)
265+
return
266+
249267
if canonical == "usage":
250268
handle_usage(ctx, console, cmd_args)
251269
return

‎src/leapflow/cli/commands/registry.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ class CommandDef:
6666
CommandDef("shortcut add", "Add a quick-reply shortcut", "Shortcuts", args_hint="<pattern> = <reply>"),
6767
CommandDef("shortcut remove", "Remove a shortcut", "Shortcuts", args_hint="<pattern>"),
6868

69+
# Gateway
70+
CommandDef("gateway", "Show connected platforms and gateway status", "Gateway"),
71+
6972
# Scheduler
7073
CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint="<skill> <cron>"),
7174
CommandDef("tasks", "List scheduled tasks", "Scheduler"),

‎src/leapflow/cli/commands/slash_handlers.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ def handle_status(ctx: "Context", console: "LeapConsole", args: str) -> None:
6767
info.append("Mode: ", style="dim")
6868
info.append(f"{mode}\n")
6969

70+
gw = getattr(ctx, "gateway_server", None)
71+
if gw is not None:
72+
statuses = gw.platform_status()
73+
connected = [s for s in statuses if s.connected]
74+
info.append("Gateway: ", style="dim")
75+
if connected:
76+
names = []
77+
for s in connected:
78+
m = gw.manifests.get(s.platform_id)
79+
names.append(m.display_name if m else s.platform_id)
80+
info.append(f"{', '.join(names)}\n", style="green")
81+
else:
82+
info.append("no connections\n", style="dim")
83+
7084
console.print(Panel(
7185
info,
7286
title="[bold cyan]LeapFlow Status[/]",
@@ -172,6 +186,67 @@ def handle_model(ctx: "Context", console: "LeapConsole", args: str) -> None:
172186
console.system(f" Example: LEAPFLOW_LLM_MODEL={model_arg} leap")
173187

174188

189+
def handle_gateway(ctx: "Context", console: "LeapConsole", args: str) -> None:
190+
"""Display gateway status: connected platforms, available integrations."""
191+
from rich.panel import Panel
192+
from rich.text import Text
193+
194+
gw = getattr(ctx, "gateway_server", None)
195+
if gw is None:
196+
console.warning("Gateway not initialised.")
197+
return
198+
199+
statuses = gw.platform_status()
200+
if not statuses:
201+
console.system("No platform manifests discovered.")
202+
return
203+
204+
info = Text()
205+
connected = [s for s in statuses if s.connected]
206+
configured = [s for s in statuses if not s.connected and s.error == "configured but not connected"]
207+
available = [s for s in statuses if not s.connected and not s.error]
208+
209+
import time
210+
211+
if connected:
212+
info.append("Connected\n", style="bold green")
213+
for s in connected:
214+
m = gw.manifests.get(s.platform_id)
215+
name = m.display_name if m else s.platform_id
216+
uptime = ""
217+
if s.connected_since > 0:
218+
secs = int(time.time() - s.connected_since)
219+
if secs < 60:
220+
uptime = f" ({secs}s)"
221+
elif secs < 3600:
222+
uptime = f" ({secs // 60}m)"
223+
else:
224+
uptime = f" ({secs // 3600}h {(secs % 3600) // 60}m)"
225+
info.append(f" ● {name}{uptime}\n", style="green")
226+
227+
if configured:
228+
info.append("Configured (not connected)\n", style="bold yellow")
229+
for s in configured:
230+
m = gw.manifests.get(s.platform_id)
231+
name = m.display_name if m else s.platform_id
232+
info.append(f" ○ {name}\n", style="yellow")
233+
234+
if available:
235+
info.append("Available\n", style="bold dim")
236+
names = [gw.manifests[s.platform_id].display_name for s in available if s.platform_id in gw.manifests]
237+
info.append(f" {', '.join(names)}\n", style="dim")
238+
239+
info.append("\n", style="dim")
240+
info.append('Say "connect to <platform>" to set up a new integration.', style="dim italic")
241+
242+
console.print(Panel(
243+
info,
244+
title="[bold cyan]Gateway[/]",
245+
border_style="bright_black",
246+
padding=(0, 2),
247+
))
248+
249+
175250
def handle_clear(ctx: "Context", console: "LeapConsole", args: str) -> None:
176251
"""Clear the terminal screen."""
177252
os.system("cls" if os.name == "nt" else "clear")

‎src/leapflow/cli/context.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,17 @@ async def _copilot_on_idle(duration_ms: int) -> None:
10781078
from leapflow.tools.registry_bootstrap import set_memory_manager
10791079
set_memory_manager(self.memory)
10801080

1081+
# ── Gateway server (late-bound tool wiring) ──
1082+
from leapflow.gateway.server import GatewayServer
1083+
from leapflow.tools.registry_bootstrap import set_gateway_server
1084+
1085+
self.gateway_server = GatewayServer(
1086+
settings.profile_dir,
1087+
extra_manifest_dirs=[settings.profile_dir / "gateway" / "manifests"],
1088+
)
1089+
self.gateway_server.discover_manifests()
1090+
set_gateway_server(self.gateway_server)
1091+
10811092
# ── Build CompressorConfig with LLM callbacks ──
10821093
from leapflow.engine.context_compressor import CompressorConfig
10831094

@@ -1845,6 +1856,14 @@ async def cleanup(self) -> None:
18451856
except Exception:
18461857
pass
18471858

1859+
# Stop gateway server
1860+
gw = getattr(self, "gateway_server", None)
1861+
if gw is not None:
1862+
try:
1863+
await gw.stop()
1864+
except Exception:
1865+
logger.debug("GatewayServer stop failed", exc_info=True)
1866+
18481867
# Cancel engine if running
18491868
if self.engine is not None:
18501869
self.engine.cancel()

‎src/leapflow/config.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def _expand_path(value: str) -> Path:
5050
"cache/frames",
5151
"cache/video",
5252
"run",
53+
"gateway",
54+
"gateway/manifests",
5355
]
5456

5557

‎src/leapflow/daemon/protocol.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,25 @@ async def shutdown(self) -> None:
174174
"""Graceful shutdown."""
175175
...
176176

177+
# ── Gateway ──────────────────────────────────────────────────────
178+
179+
async def gateway_connect(
180+
self,
181+
platform: str,
182+
credentials: Dict[str, str],
183+
options: Optional[Dict[str, Any]] = None,
184+
) -> Dict[str, Any]:
185+
"""Connect a platform via the gateway."""
186+
...
187+
188+
async def gateway_disconnect(self, platform: str) -> Dict[str, Any]:
189+
"""Disconnect a platform."""
190+
...
191+
192+
async def gateway_status(self) -> List[Dict[str, Any]]:
193+
"""Return status of all gateway platforms."""
194+
...
195+
177196

178197
# ══════════════════════════════════════════════════════════════════════
179198
# Method registry — maps JSON-RPC method names to LeapService methods
@@ -191,4 +210,7 @@ async def shutdown(self) -> None:
191210
"scheduler.arm": "scheduler_arm",
192211
"daemon.status": "status",
193212
"daemon.shutdown": "shutdown",
213+
"gateway.connect": "gateway_connect",
214+
"gateway.disconnect": "gateway_disconnect",
215+
"gateway.status": "gateway_status",
194216
}

0 commit comments

Comments
 (0)