3 커밋 594f62f7b4 ... 9a7ba815ea

작성자 SHA1 메시지 날짜
  Jax Docker 9a7ba815ea feat: add app management features including listing, creating, and updating apps 1 개월 전
  Jax Docker 4f64c0a211 chore: add CLAUDE.md, API plan doc, and local settings 1 개월 전
  Jax Docker 2f826a8c6b feat: add session runtime targets and SSE streaming execution 1 개월 전
61개의 변경된 파일4765개의 추가작업 그리고 166개의 파일을 삭제
  1. 13 0
      .claude/settings.json
  2. 2 1
      .claude/settings.local.json
  3. 65 0
      CLAUDE.md
  4. 0 1
      deployments/docker/python-service.Dockerfile
  5. 463 0
      docs/agent-platform-application-api-plan.md
  6. 6 0
      libs/core-domain/src/core_domain/__init__.py
  7. 17 0
      libs/core-domain/src/core_domain/model_contracts.py
  8. 90 0
      services/api-gateway/alembic/versions/20260514_0001_add_app_tables.py
  9. 1031 3
      services/api-gateway/app/api/routes.py
  10. 11 1
      services/api-gateway/app/db/models/__init__.py
  11. 18 0
      services/api-gateway/app/db/models/app_api_key.py
  12. 16 0
      services/api-gateway/app/db/models/app_definition.py
  13. 21 0
      services/api-gateway/app/db/models/app_invocation_audit.py
  14. 190 1
      services/api-gateway/app/domain/repositories.py
  15. 2 0
      services/api-gateway/app/infrastructure/request_context.py
  16. 156 1
      services/api-gateway/app/schemas/gateway.py
  17. 34 0
      services/knowledge-service/alembic/versions/20260514_0003_resize_embedding_vector.py
  18. 237 0
      services/knowledge-service/app/application/chunking.py
  19. 1 5
      services/knowledge-service/app/application/document_parsers.py
  20. 93 4
      services/knowledge-service/app/application/embeddings.py
  21. 56 3
      services/knowledge-service/app/application/retrieval.py
  22. 28 10
      services/knowledge-service/app/application/services.py
  23. 5 3
      services/knowledge-service/app/bootstrap/settings.py
  24. 3 1
      services/knowledge-service/app/db/models/knowledge_chunk.py
  25. 12 0
      services/model-gateway-service/app/api/routes.py
  26. 33 1
      services/model-gateway-service/app/application/services.py
  27. 83 1
      services/model-gateway-service/app/infrastructure/provider.py
  28. 28 0
      services/session-service/alembic/versions/20260512_0002_session_runtime_targets.py
  29. 34 2
      services/session-service/app/api/routes.py
  30. 14 2
      services/session-service/app/application/services.py
  31. 3 0
      services/session-service/app/db/models/session.py
  32. 39 1
      services/session-service/app/domain/repositories.py
  33. 10 0
      services/session-service/app/schemas/run_request.py
  34. 10 0
      services/session-service/app/schemas/session.py
  35. 313 87
      services/team-service/app/application/services.py
  36. 6 1
      services/team-service/app/infrastructure/agent_client.py
  37. 205 1
      tests/test_team_service.py
  38. 1 1
      web/index.html
  39. 3 0
      web/src/App.tsx
  40. 67 0
      web/src/api/apps.ts
  41. 1 0
      web/src/api/index.ts
  42. 2 2
      web/src/api/mock.ts
  43. 87 2
      web/src/api/sessions.ts
  44. 9 2
      web/src/api/teams.ts
  45. 2 2
      web/src/hooks/useApps.ts
  46. 4 0
      web/src/lib/constants.ts
  47. 59 1
      web/src/locales/en.json
  48. 59 1
      web/src/locales/zh.json
  49. 194 0
      web/src/pages/apps/AppsPage.tsx
  50. 173 0
      web/src/pages/apps/components/AppApiKeysPanel.tsx
  51. 69 0
      web/src/pages/apps/components/AppAuditsPanel.tsx
  52. 222 0
      web/src/pages/apps/components/AppDetail.tsx
  53. 174 0
      web/src/pages/apps/components/CreateAppDialog.tsx
  54. 58 8
      web/src/pages/sessions/SessionChatPage.tsx
  55. 26 3
      web/src/pages/sessions/components/ChatPanel.tsx
  56. 100 5
      web/src/pages/sessions/components/CreateSessionDialog.tsx
  57. 4 3
      web/src/pages/teams/components/CreateTeamDialog.tsx
  58. 2 2
      web/src/pages/teams/components/TeamRuns.tsx
  59. 1 1
      web/src/stores/ui.ts
  60. 82 3
      web/src/types/app.ts
  61. 18 0
      web/src/types/session.ts

+ 13 - 0
.claude/settings.json

@@ -0,0 +1,13 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/knowledge-service/app/application/chunking.py'\\).read\\(\\)\\); print\\('chunking OK'\\)\")",
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/knowledge-service/app/application/document_parsers.py'\\).read\\(\\)\\); print\\('parsers OK'\\)\")",
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/knowledge-service/app/application/services.py'\\).read\\(\\)\\); print\\('services OK'\\)\")",
+      "Bash(python -c ' *)",
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/team-service/app/application/services.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")",
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/knowledge-service/app/application/services.py'\\).read\\(\\)\\); print\\('OK'\\)\")",
+      "Bash(python -c \"import ast; ast.parse\\(open\\('services/team-service/app/infrastructure/agent_client.py'\\).read\\(\\)\\); print\\('Syntax OK'\\)\")"
+    ]
+  }
+}

+ 2 - 1
.claude/settings.local.json

@@ -11,7 +11,8 @@
       "Bash(python scripts/search.py \"workflow flowchart status pipeline\" --domain chart)",
       "Bash(python scripts/search.py \"workflow flowchart status pipeline\" --domain chart)",
       "Bash(python scripts/search.py \"sidebar navigation dashboard admin\" --domain ux)",
       "Bash(python scripts/search.py \"sidebar navigation dashboard admin\" --domain ux)",
       "Bash(npm install:*)",
       "Bash(npm install:*)",
-      "Bash(npx tsc:*)"
+      "Bash(npx tsc:*)",
+      "*"
     ]
     ]
   }
   }
 }
 }

+ 65 - 0
CLAUDE.md

@@ -0,0 +1,65 @@
+# CLAUDE.md
+
+Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
+
+**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+
+## 1. Think Before Coding
+
+**Don't assume. Don't hide confusion. Surface tradeoffs.**
+
+Before implementing:
+- State your assumptions explicitly. If uncertain, ask.
+- If multiple interpretations exist, present them - don't pick silently.
+- If a simpler approach exists, say so. Push back when warranted.
+- If something is unclear, stop. Name what's confusing. Ask.
+
+## 2. Simplicity First
+
+**Minimum code that solves the problem. Nothing speculative.**
+
+- No features beyond what was asked.
+- No abstractions for single-use code.
+- No "flexibility" or "configurability" that wasn't requested.
+- No error handling for impossible scenarios.
+- If you write 200 lines and it could be 50, rewrite it.
+
+Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
+
+## 3. Surgical Changes
+
+**Touch only what you must. Clean up only your own mess.**
+
+When editing existing code:
+- Don't "improve" adjacent code, comments, or formatting.
+- Don't refactor things that aren't broken.
+- Match existing style, even if you'd do it differently.
+- If you notice unrelated dead code, mention it - don't delete it.
+
+When your changes create orphans:
+- Remove imports/variables/functions that YOUR changes made unused.
+- Don't remove pre-existing dead code unless asked.
+
+The test: Every changed line should trace directly to the user's request.
+
+## 4. Goal-Driven Execution
+
+**Define success criteria. Loop until verified.**
+
+Transform tasks into verifiable goals:
+- "Add validation" → "Write tests for invalid inputs, then make them pass"
+- "Fix the bug" → "Write a test that reproduces it, then make it pass"
+- "Refactor X" → "Ensure tests pass before and after"
+
+For multi-step tasks, state a brief plan:
+```
+1. [Step] → verify: [check]
+2. [Step] → verify: [check]
+3. [Step] → verify: [check]
+```
+
+Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+
+---
+
+**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

+ 0 - 1
deployments/docker/python-service.Dockerfile

@@ -15,7 +15,6 @@ COPY services ./services
 RUN pip install --no-cache-dir \
 RUN pip install --no-cache-dir \
     -e ./libs/core-shared \
     -e ./libs/core-shared \
     -e ./libs/core-domain \
     -e ./libs/core-domain \
-    -e ./libs/core-dsl \
     -e ./libs/core-events \
     -e ./libs/core-events \
     -e ./libs/core-db \
     -e ./libs/core-db \
     -e ./${SERVICE_PATH}
     -e ./${SERVICE_PATH}

+ 463 - 0
docs/agent-platform-application-api-plan.md

@@ -0,0 +1,463 @@
+# 智能体中台应用开放能力设计规划
+
+## 1. 目标定位
+
+本项目要建设一个智能体中台,用于配置、开发、发布智能体能力,并将能力以应用形式开放给外部系统调用。
+
+第一阶段的目标不是重新搭建一套运行时,而是在当前 `auto-platform` 已有多服务架构上补齐应用开放闭环:
+
+- 在平台内配置 Agent、Team、工具、技能、模型和知识能力。
+- 将一个可运行能力发布成应用。
+- 为应用生成 API Key。
+- 外部系统通过统一 API 调用应用。
+- 同步和流式调用都必须支持,其中流式调用是 V0.1 核心能力。
+- 平台记录调用审计、运行结果、错误和基础用量信息。
+
+当前仓库已有 `api-gateway`、`agent-service`、`session-service`、`auth-service`、`model-gateway-service`、`tool-service`、`skill-service`、`team-service` 等服务。本设计优先复用这些服务边界,不另起一套重复平台。
+
+## 2. 核心概念
+
+### 2.1 应用
+
+应用是对外发布单元。外部调用方不需要理解内部 Agent、Session、Run、Tool 的细节,只需要知道应用编码、调用地址和 API Key。
+
+一个应用至少包含:
+
+- 应用名称、编码、描述。
+- 应用状态:`draft`、`published`、`disabled`。
+- 运行目标类型:`agent`、`team`,后续扩展 `workflow`。
+- 运行目标 ID:绑定已发布 Agent 或 Team。
+- 默认调用配置:模型参数、输入变量、是否支持流式、超时策略。
+- API Key 列表。
+- 调用审计和运行记录。
+
+### 2.2 API Key
+
+API Key 用于外部系统调用应用接口。
+
+设计原则:
+
+- Key 明文只在创建时返回一次。
+- 数据库存储 key hash 和 key prefix,不存明文。
+- Key 必须绑定应用。
+- Key 可配置状态、过期时间和 scope。
+- Key 可被禁用或轮换。
+- 每次调用更新最后使用时间,并写入审计。
+
+当前 `api-gateway` 已有 API Key 生成、hash、prefix、状态、过期时间等基础字段,可在此基础上扩展应用绑定关系。
+
+### 2.3 外部调用
+
+外部调用分为两类:
+
+- 同步调用:请求结束后一次性返回完整回答。
+- 流式调用:使用 SSE 返回增量事件,适合聊天、长文本生成和工具执行进度展示。
+
+V0.1 必须支持 Agent 流式调用。Team 流式能力如果当前链路不足,先在 V0.2 补齐;V0.1 的 Team 调用可先走同步路径。
+
+## 3. 总体架构
+
+推荐调用路径:
+
+```text
+External Client
+  -> api-gateway
+  -> API Key validation
+  -> Application resolution
+  -> session-service
+  -> agent-service / team-service
+  -> model-gateway-service / tool-service / skill-service / memory-service
+  -> api-gateway
+  -> External Client
+```
+
+服务职责:
+
+- `api-gateway`:统一开放入口、API Key 校验、应用解析、限流、审计、SSE 代理。
+- `auth-service`:用户、角色和后台管理权限;后续可承接更完整的凭证治理。
+- `session-service`:会话、消息、run request 的持久化和状态管理。
+- `agent-service`:Agent 定义、配置、运行和 Agent 流式执行。
+- `team-service`:多 Agent 团队定义和运行。
+- `model-gateway-service`:统一模型供应商和模型调用。
+- `tool-service`、`skill-service`:工具、技能和 MCP 能力绑定。
+
+当前代码中 `agent-service` 已存在 `POST /runs/{agent_run_id}/execute-stream`,可返回 `text/event-stream`。`api-gateway` 的通用代理也已有 SSE 转发能力,但 `/gateway/sessions/execute` 在 `stream=true` 时仍返回未实现。因此应用级流式接口需要作为 V0.1 补齐重点。
+
+## 4. 应用模块设计
+
+### 4.1 应用数据模型
+
+建议新增应用实体:
+
+```text
+app_definition
+- id
+- code
+- name
+- description
+- status
+- target_type
+- target_id
+- owner_user_id
+- settings_json
+- created_time
+- updated_time
+```
+
+`target_type` 首版支持:
+
+- `agent`
+- `team`
+
+后续支持:
+
+- `workflow`
+
+`settings_json` 可保存:
+
+- `stream_enabled`
+- `default_inputs`
+- `timeout_seconds`
+- `rate_limit`
+- `metadata`
+
+### 4.2 应用 API Key 绑定
+
+建议扩展或新增应用 Key 关系:
+
+```text
+app_api_key
+- id
+- app_id
+- name
+- key_prefix
+- key_hash
+- status
+- scopes
+- expires_time
+- last_used_time
+- created_time
+- updated_time
+```
+
+scope 首版可以保持简单:
+
+- `app:invoke`
+- `app:stream`
+- `app:admin`
+
+V0.1 只需要 `app:invoke` 和 `app:stream`。
+
+### 4.3 应用调用审计
+
+建议新增调用审计:
+
+```text
+app_invocation_audit
+- id
+- app_id
+- api_key_prefix
+- request_id
+- session_id
+- run_request_id
+- target_type
+- target_id
+- invoke_type
+- status
+- duration_ms
+- error_code
+- error_message
+- client_metadata_json
+- created_time
+```
+
+`invoke_type`:
+
+- `sync`
+- `stream`
+
+## 5. 管理端接口设计
+
+管理端接口通过后台登录态访问,不直接使用外部 API Key。
+
+### 5.1 应用管理
+
+```http
+POST /gateway/apps
+POST /gateway/apps/list
+POST /gateway/apps/detail
+POST /gateway/apps/update
+POST /gateway/apps/status
+```
+
+创建应用请求示例:
+
+```json
+{
+  "code": "customer_support",
+  "name": "Customer Support",
+  "description": "External customer support assistant.",
+  "target_type": "agent",
+  "target_id": "agent_support",
+  "settings_json": {
+    "stream_enabled": true,
+    "timeout_seconds": 120
+  }
+}
+```
+
+### 5.2 应用 API Key
+
+```http
+POST /gateway/apps/{app_id}/api-keys
+POST /gateway/apps/{app_id}/api-keys/list
+POST /gateway/apps/{app_id}/api-keys/status
+```
+
+创建 Key 请求示例:
+
+```json
+{
+  "name": "production integration",
+  "scopes": "app:invoke app:stream",
+  "expires_time": null
+}
+```
+
+创建 Key 响应示例:
+
+```json
+{
+  "id": "key_001",
+  "name": "production integration",
+  "key_prefix": "agp_xxxxxxxx",
+  "api_key": "agp_full_plaintext_key_returned_once",
+  "status": "active",
+  "scopes": "app:invoke app:stream",
+  "expires_time": null,
+  "created_time": "2026-05-14T10:00:00Z"
+}
+```
+
+## 6. 外部调用接口设计
+
+外部调用统一走 `api-gateway`,请求头支持两种形式:
+
+```http
+Authorization: Bearer agp_xxx
+```
+
+或:
+
+```http
+X-API-Key: agp_xxx
+```
+
+### 6.1 同步调用
+
+```http
+POST /gateway/openapi/apps/{app_code}/chat
+Content-Type: application/json
+Authorization: Bearer agp_xxx
+```
+
+请求体:
+
+```json
+{
+  "user_id": "external-user-001",
+  "session_id": "optional-session-id",
+  "message": "帮我查询订单状态",
+  "inputs": {
+    "order_no": "SO123456"
+  },
+  "metadata": {
+    "source": "crm",
+    "trace_id": "external-trace-001"
+  }
+}
+```
+
+响应体:
+
+```json
+{
+  "request_id": "req_001",
+  "app_code": "customer_support",
+  "session_id": "ses_001",
+  "run_request_id": "run_req_001",
+  "target_type": "agent",
+  "target_id": "agent_support",
+  "status": "completed",
+  "output_text": "订单 SO123456 当前正在运输中。",
+  "output_json": null,
+  "error": null
+}
+```
+
+### 6.2 流式调用
+
+```http
+POST /gateway/openapi/apps/{app_code}/chat/stream
+Content-Type: application/json
+Accept: text/event-stream
+Authorization: Bearer agp_xxx
+```
+
+请求体与同步调用保持一致。
+
+响应类型:
+
+```http
+Content-Type: text/event-stream
+Cache-Control: no-cache
+X-Accel-Buffering: no
+```
+
+事件示例:
+
+```text
+event: started
+data: {"request_id":"req_001","session_id":"ses_001","run_request_id":"run_req_001"}
+
+event: delta
+data: {"text":"订单 "}
+
+event: delta
+data: {"text":"SO123456 当前正在运输中。"}
+
+event: completed
+data: {"status":"completed","output_text":"订单 SO123456 当前正在运输中。"}
+```
+
+失败事件示例:
+
+```text
+event: failed
+data: {"status":"failed","error_code":"model_error","error_message":"model request failed"}
+```
+
+### 6.3 流式实现要求
+
+V0.1 的流式调用必须满足:
+
+- API Key 和应用状态在开始流式响应前完成校验。
+- 响应必须使用标准 SSE 格式。
+- 每个事件必须是完整 JSON,不能输出半截 JSON。
+- 网关要透传下游 Agent stream 的 delta/completed/failed 事件。
+- 客户端断开连接时,网关必须关闭上游连接。
+- 调用审计至少记录最终状态、耗时和错误。
+- 如果目标类型暂不支持流式,返回明确 422,而不是静默降级。
+
+## 7. 后台管理 UI 设计
+
+新增“应用”模块,建议放在主导航中,与 Agents、Teams、Skills、Tools、Models 同级。
+
+### 7.1 应用列表
+
+列表展示:
+
+- 应用名称和 code。
+- 状态。
+- 绑定目标类型和目标名称。
+- 是否支持流式。
+- API Key 数量。
+- 最近调用时间。
+
+主要操作:
+
+- 新建应用。
+- 发布 / 停用应用。
+- 进入详情。
+
+### 7.2 应用详情
+
+详情页包含四个区域:
+
+- 基础配置:名称、code、描述、状态、目标类型、目标 ID。
+- 调用方式:同步接口、流式接口、请求示例、响应示例。
+- API Key:创建、禁用、查看 prefix、复制创建时返回的明文 Key。
+- 调用日志:请求时间、状态、耗时、Key prefix、错误信息。
+
+### 7.3 创建应用流程
+
+推荐表单步骤:
+
+1. 填写应用名称、code、描述。
+2. 选择运行目标:Agent 或 Team。
+3. 配置是否支持流式。
+4. 创建应用。
+5. 生成 API Key。
+6. 复制同步和流式调用示例。
+
+首版不做复杂向导,保持一个表单和一个详情页即可。
+
+## 8. V0.1 实施范围
+
+V0.1 必须完成:
+
+- 应用 CRUD。
+- 应用绑定 Agent。
+- 应用发布和停用。
+- 应用 API Key 创建、列表、禁用。
+- 外部同步调用接口。
+- 外部 Agent 流式调用接口。
+- 调用审计。
+- 管理端应用列表和详情页。
+- 同步和流式调用示例。
+
+V0.1 暂不做:
+
+- 多租户 workspace partition。
+- 复杂计费。
+- 应用 marketplace。
+- Workflow 可视化发布。
+- Team 完整流式编排。
+- 多版本灰度发布。
+
+## 9. 后续阶段
+
+### V0.2
+
+- Team 流式调用。
+- 应用调用限流。
+- API Key scope 更细粒度控制。
+- Key 轮换。
+- 调用统计和错误分析。
+
+### V0.3
+
+- Workflow 作为应用目标。
+- 应用版本发布。
+- Webhook 回调。
+- SDK 示例。
+- 前端流式调试控制台。
+
+### V0.4
+
+- 灰度发布。
+- 配额和用量报表。
+- 企业权限治理。
+- 审计导出。
+- 应用级 SLA 和告警。
+
+## 10. 验收标准
+
+文档交付验收:
+
+- 文件存在:`docs/agent-platform-application-api-plan.md`。
+- 内容包含应用模块、API Key、同步调用、流式调用、后台 UI、实施阶段和验收标准。
+- Markdown 中文可读,无乱码。
+- 不修改任何后端或前端代码。
+
+后续实现验收:
+
+- 可以创建应用并绑定已发布 Agent。
+- 可以生成应用 API Key,且明文只返回一次。
+- 使用 API Key 可以调用同步接口并得到完整回答。
+- 使用 API Key 可以调用流式接口并收到 SSE delta 事件。
+- 停用应用后,同步和流式接口都拒绝调用。
+- 停用 API Key 后,同步和流式接口都拒绝调用。
+- 流式连接断开后,上游连接被释放。
+- 调用审计能记录 app、key prefix、request id、session id、run id、状态、耗时和错误。
+

+ 6 - 0
libs/core-domain/src/core_domain/__init__.py

@@ -54,6 +54,9 @@ from .model_contracts import (
     ChatCompletionRequestContract,
     ChatCompletionRequestContract,
     ChatCompletionResponseContract,
     ChatCompletionResponseContract,
     ChatMessageContract,
     ChatMessageContract,
+    EmbeddingDataItem,
+    EmbeddingRequestContract,
+    EmbeddingResponseContract,
 )
 )
 from .scheduler_contracts import ScheduledJobContract, ScheduledJobStatus, ScheduledJobType
 from .scheduler_contracts import ScheduledJobContract, ScheduledJobStatus, ScheduledJobType
 from .service import ServiceDescriptor, ServiceHealth
 from .service import ServiceDescriptor, ServiceHealth
@@ -108,6 +111,9 @@ __all__ = [
     "ChatCompletionRequestContract",
     "ChatCompletionRequestContract",
     "ChatCompletionResponseContract",
     "ChatCompletionResponseContract",
     "ChatMessageContract",
     "ChatMessageContract",
+    "EmbeddingDataItem",
+    "EmbeddingRequestContract",
+    "EmbeddingResponseContract",
     "HumanTaskContract",
     "HumanTaskContract",
     "HumanTaskCreateContract",
     "HumanTaskCreateContract",
     "HumanTaskStatus",
     "HumanTaskStatus",

+ 17 - 0
libs/core-domain/src/core_domain/model_contracts.py

@@ -25,3 +25,20 @@ class ChatCompletionResponseContract(BaseModel):
     tool_calls_json: list[dict[str, JSONValue]] = Field(default_factory=list)
     tool_calls_json: list[dict[str, JSONValue]] = Field(default_factory=list)
     usage_json: dict[str, JSONValue] = Field(default_factory=dict)
     usage_json: dict[str, JSONValue] = Field(default_factory=dict)
     raw_response_json: dict[str, JSONValue] = Field(default_factory=dict)
     raw_response_json: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class EmbeddingRequestContract(BaseModel):
+    model: str | None = None
+    input: str | list[str]
+    dimensions: int | None = None
+
+
+class EmbeddingDataItem(BaseModel):
+    embedding: list[float]
+    index: int
+
+
+class EmbeddingResponseContract(BaseModel):
+    model: str | None = None
+    data: list[EmbeddingDataItem] = Field(default_factory=list)
+    usage_json: dict[str, JSONValue] = Field(default_factory=dict)

+ 90 - 0
services/api-gateway/alembic/versions/20260514_0001_add_app_tables.py

@@ -0,0 +1,90 @@
+"""add app_definition, app_api_key, app_invocation_audit tables
+
+Revision ID: 20260514_0001
+Revises: 20260429_9001
+Create Date: 2026-05-14 10:00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260514_0001"
+down_revision: str | None = "20260429_9001"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+MIXIN_COLUMNS = [
+    sa.Column("id", sa.String(length=36), nullable=False),
+    sa.Column("created_by", sa.String(length=36), nullable=True),
+    sa.Column("updated_by", sa.String(length=36), nullable=True),
+    sa.Column("created_time", sa.DateTime(), nullable=False),
+    sa.Column("updated_time", sa.DateTime(), nullable=False),
+    sa.Column("deleted_time", sa.DateTime(), nullable=True),
+]
+
+
+def upgrade() -> None:
+    op.create_table(
+        "app_definition",
+        sa.Column("code", sa.String(length=64), nullable=False),
+        sa.Column("name", sa.String(length=128), nullable=False),
+        sa.Column("description", sa.Text(), nullable=True),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("target_type", sa.String(length=32), nullable=False),
+        sa.Column("target_id", sa.String(length=36), nullable=False),
+        sa.Column("owner_user_id", sa.String(length=36), nullable=True),
+        sa.Column("settings_json", sa.Text(), nullable=True),
+        *MIXIN_COLUMNS,
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_app_definition_code", "app_definition", ["code"], unique=True)
+    op.create_index("ix_app_definition_status", "app_definition", ["status"], unique=False)
+
+    op.create_table(
+        "app_api_key",
+        sa.Column("app_id", sa.String(length=36), nullable=False),
+        sa.Column("name", sa.String(length=128), nullable=False),
+        sa.Column("key_prefix", sa.String(length=16), nullable=False),
+        sa.Column("key_hash", sa.String(length=128), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("scopes", sa.Text(), nullable=True),
+        sa.Column("expires_time", sa.DateTime(), nullable=True),
+        sa.Column("last_used_time", sa.DateTime(), nullable=True),
+        *MIXIN_COLUMNS,
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_app_api_key_app_id", "app_api_key", ["app_id"], unique=False)
+    op.create_index("ix_app_api_key_key_prefix", "app_api_key", ["key_prefix"], unique=False)
+    op.create_index("ix_app_api_key_key_hash", "app_api_key", ["key_hash"], unique=True)
+    op.create_index("ix_app_api_key_status", "app_api_key", ["status"], unique=False)
+
+    op.create_table(
+        "app_invocation_audit",
+        sa.Column("app_id", sa.String(length=36), nullable=False),
+        sa.Column("api_key_prefix", sa.String(length=16), nullable=True),
+        sa.Column("request_id", sa.String(length=64), nullable=False),
+        sa.Column("session_id", sa.String(length=36), nullable=True),
+        sa.Column("run_request_id", sa.String(length=36), nullable=True),
+        sa.Column("target_type", sa.String(length=32), nullable=False),
+        sa.Column("target_id", sa.String(length=36), nullable=False),
+        sa.Column("invoke_type", sa.String(length=16), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("duration_ms", sa.Integer(), nullable=False),
+        sa.Column("error_code", sa.String(length=64), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("client_metadata_json", sa.Text(), nullable=True),
+        *MIXIN_COLUMNS,
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_app_invocation_audit_app_id", "app_invocation_audit", ["app_id"], unique=False)
+    op.create_index("ix_app_invocation_audit_request_id", "app_invocation_audit", ["request_id"], unique=False)
+    op.create_index("ix_app_invocation_audit_session_id", "app_invocation_audit", ["session_id"], unique=False)
+    op.create_index("ix_app_invocation_audit_status", "app_invocation_audit", ["status"], unique=False)
+
+
+def downgrade() -> None:
+    op.drop_table("app_invocation_audit")
+    op.drop_table("app_api_key")
+    op.drop_table("app_definition")

+ 1031 - 3
services/api-gateway/app/api/routes.py

@@ -1,16 +1,29 @@
 import asyncio
 import asyncio
-from typing import Annotated
+import json
+from datetime import datetime
+from time import perf_counter
+from typing import Annotated, AsyncIterator
+from uuid import uuid4
 
 
 from core_domain import ServiceDescriptor, ServiceHealth
 from core_domain import ServiceDescriptor, ServiceHealth
-from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
+import httpx
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, StreamingResponse
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
+from pydantic import BaseModel
 
 
 from app.bootstrap.settings import ApiGatewaySettings
 from app.bootstrap.settings import ApiGatewaySettings
 from app.db.session import get_db
 from app.db.session import get_db
-from app.domain.repositories import ApiKeyRepository, GatewayRequestAuditRepository
+from app.domain.repositories import (
+    ApiKeyRepository,
+    AppApiKeyRepository,
+    AppDefinitionRepository,
+    AppInvocationAuditRepository,
+    GatewayRequestAuditRepository,
+)
 from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
 from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
 from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
 from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
+from core_shared.security import build_internal_service_headers
 from app.schemas.gateway import (
 from app.schemas.gateway import (
     ApiKeyCreateRequest,
     ApiKeyCreateRequest,
     ApiKeyCreateResponse,
     ApiKeyCreateResponse,
@@ -18,16 +31,52 @@ from app.schemas.gateway import (
     ApiKeyResponse,
     ApiKeyResponse,
     ApiKeyStatusPostRequest,
     ApiKeyStatusPostRequest,
     ApiKeyStatusUpdateRequest,
     ApiKeyStatusUpdateRequest,
+    AppApiKeyCreateRequest,
+    AppApiKeyCreateResponse,
+    AppApiKeyListRequest,
+    AppApiKeyResponse,
+    AppApiKeyStatusUpdateRequest,
+    AppAuditListRequest,
+    AppCreateRequest,
+    AppDetailRequest,
+    AppInvocationAuditResponse,
+    AppListRequest,
+    AppResponse,
+    AppStatusUpdateRequest,
+    AppUpdateRequest,
     GatewayAuditServiceStats,
     GatewayAuditServiceStats,
     GatewayAuditStatsResponse,
     GatewayAuditStatsResponse,
     GatewayRequestAuditResponse,
     GatewayRequestAuditResponse,
     GatewayServicesHealthResponse,
     GatewayServicesHealthResponse,
+    OpenApiChatRequest,
+    OpenApiChatResponse,
 )
 )
 
 
 router = APIRouter()
 router = APIRouter()
 DbSession = Annotated[Session, Depends(get_db)]
 DbSession = Annotated[Session, Depends(get_db)]
 
 
 
 
+class SessionExecuteRequest(BaseModel):
+    session_id: str
+    message_text: str
+    stream: bool = False
+
+
+class SessionExecuteResponse(BaseModel):
+    session_id: str
+    run_request_id: str
+    target_type: str
+    target_id: str
+    target_config_id: str | None = None
+    request_status: str
+    user_message_id: str
+    assistant_message_id: str | None = None
+    agent_run_id: str | None = None
+    team_run_id: str | None = None
+    output_text: str | None = None
+    error_message: str | None = None
+
+
 @router.get("/health", response_model=ServiceDescriptor)
 @router.get("/health", response_model=ServiceDescriptor)
 def health_check(db: DbSession) -> ServiceDescriptor:
 def health_check(db: DbSession) -> ServiceDescriptor:
     db.execute(text("SELECT 1"))
     db.execute(text("SELECT 1"))
@@ -241,6 +290,862 @@ async def downstream_health_check(
         downstream_services=downstream_services)
         downstream_services=downstream_services)
 
 
 
 
+@router.post("/gateway/sessions/execute")
+async def execute_session(
+    payload: SessionExecuteRequest,
+    request: Request,
+    settings: GatewaySettingsDep):
+    if payload.stream:
+        return StreamingResponse(
+            _stream_session_execute(payload, request, settings),
+            media_type="text/event-stream",
+            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+
+    targets = build_proxy_targets(settings)
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    team_target = targets["team-service"]
+    headers = _build_internal_headers(request, settings)
+
+    async with httpx_client(settings.proxy_timeout_seconds) as client:
+        session = await _post_json(
+            client=client,
+            target=session_target,
+            path="detail",
+            payload={"session_id": payload.session_id},
+            headers=headers)
+
+        target_type = _get_string(session, "runtime_target_type")
+        target_id = _get_string(session, "runtime_target_id")
+        target_config_id = _get_optional_string(session, "runtime_target_config_id")
+        if target_type not in {"agent", "team"} or not target_id:
+            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+
+        run_request_payload = {
+            "target_type": target_type,
+            "target_id": target_id,
+            "target_config_id": target_config_id,
+            "mode": "production",
+            "input_text": payload.message_text,
+        }
+        run_request = await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests",
+            payload={
+                "session_id": payload.session_id,
+                "app_config_id": target_config_id or target_id,
+                "workflow_config_id": target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            },
+            headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client,
+            target=session_target,
+            path="messages",
+            payload={
+                "session_id": payload.session_id,
+                "turn_id": run_request_id,
+                "role": "user",
+                "content_type": "text",
+                "content_text": payload.message_text,
+                "content_json": {},
+            },
+            headers=headers)
+        user_message_id = _get_string(user_message, "id")
+
+        await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id,
+                "request_status": "running",
+                "request_payload_json": {
+                    **run_request_payload,
+                    "user_message_id": user_message_id,
+                },
+            },
+            headers=headers)
+
+        assistant_message_id: str | None = None
+        agent_run_id: str | None = None
+        team_run_id: str | None = None
+        output_text: str | None = None
+        error_message: str | None = None
+        request_status = "completed"
+
+        try:
+            if target_type == "agent":
+                agent_run = await _post_json(
+                    client=client,
+                    target=agent_target,
+                    path="runs",
+                    payload={
+                        "agent_id": target_id,
+                        "agent_config_id": target_config_id,
+                        "session_id": payload.session_id,
+                        "input_text": payload.message_text,
+                        "input_json": {
+                            "source": "session",
+                            "run_request_id": run_request_id,
+                        },
+                    },
+                    headers=headers)
+                agent_run_id = _get_string(agent_run, "id")
+                execute_result = await _post_json(
+                    client=client,
+                    target=agent_target,
+                    path="runs/execute",
+                    payload={
+                        "agent_run_id": agent_run_id,
+                        "dry_run": False,
+                    },
+                    headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+            else:
+                team_run = await _post_json(
+                    client=client,
+                    target=team_target,
+                    path="runs",
+                    payload={
+                        "team_id": target_id,
+                        "team_config_id": target_config_id,
+                        "session_id": payload.session_id,
+                        "input_text": payload.message_text,
+                        "input_json": {
+                            "source": "session",
+                            "run_request_id": run_request_id,
+                        },
+                        "enqueue": True,
+                    },
+                    headers=headers)
+                team_run_id = _get_string(team_run, "id")
+                execute_result = await _post_json(
+                    client=client,
+                    target=team_target,
+                    path=f"runs/{team_run_id}/execute",
+                    payload={
+                        "dry_run": False,
+                    },
+                    headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+
+            if error_message:
+                request_status = "failed"
+
+            if output_text:
+                assistant_message = await _post_json(
+                    client=client,
+                    target=session_target,
+                    path="messages",
+                    payload={
+                        "session_id": payload.session_id,
+                        "turn_id": run_request_id,
+                        "role": "assistant",
+                        "content_type": "text",
+                        "content_text": output_text,
+                        "content_json": {},
+                    },
+                    headers=headers)
+                assistant_message_id = _get_string(assistant_message, "id")
+        except HTTPException as exc:
+            request_status = "failed"
+            error_message = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+
+        await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id,
+                "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload,
+                    "user_message_id": user_message_id,
+                    "assistant_message_id": assistant_message_id,
+                    "agent_run_id": agent_run_id,
+                    "team_run_id": team_run_id,
+                    "output_text": output_text,
+                    "error_message": error_message,
+                },
+            },
+            headers=headers)
+
+    return SessionExecuteResponse(
+        session_id=payload.session_id,
+        run_request_id=run_request_id,
+        target_type=target_type,
+        target_id=target_id,
+        target_config_id=target_config_id,
+        request_status=request_status,
+        user_message_id=user_message_id,
+        assistant_message_id=assistant_message_id,
+        agent_run_id=agent_run_id,
+        team_run_id=team_run_id,
+        output_text=output_text,
+        error_message=error_message)
+
+
+async def _stream_session_execute(
+    payload: SessionExecuteRequest,
+    request: Request,
+    settings: ApiGatewaySettings):
+    targets = build_proxy_targets(settings)
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    team_target = targets["team-service"]
+    headers = _build_internal_headers(request, settings)
+    client = httpx.AsyncClient(timeout=settings.proxy_timeout_seconds)
+
+    try:
+        session = await _post_json(
+            client=client, target=session_target, path="detail",
+            payload={"session_id": payload.session_id}, headers=headers)
+        target_type = _get_string(session, "runtime_target_type")
+        target_id = _get_string(session, "runtime_target_id")
+        target_config_id = _get_optional_string(session, "runtime_target_config_id")
+        if target_type not in {"agent", "team"} or not target_id:
+            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+
+        run_request_payload = {
+            "target_type": target_type, "target_id": target_id,
+            "target_config_id": target_config_id, "mode": "production",
+            "input_text": payload.message_text,
+        }
+        run_request = await _post_json(
+            client=client, target=session_target, path="run-requests",
+            payload={
+                "session_id": payload.session_id,
+                "app_config_id": target_config_id or target_id,
+                "workflow_config_id": target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            }, headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client, target=session_target, path="messages",
+            payload={
+                "session_id": payload.session_id, "turn_id": run_request_id,
+                "role": "user", "content_type": "text",
+                "content_text": payload.message_text, "content_json": {},
+            }, headers=headers)
+        user_message_id = _get_string(user_message, "id")
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": "running",
+                "request_payload_json": {**run_request_payload, "user_message_id": user_message_id},
+            }, headers=headers)
+
+        yield _sse("session.execute.started", {
+            "run_request_id": run_request_id, "user_message_id": user_message_id,
+            "target_type": target_type, "target_id": target_id,
+        })
+
+        output_text = ""
+        error_message: str | None = None
+        agent_run_id: str | None = None
+        team_run_id: str | None = None
+
+        if target_type == "agent":
+            agent_run = await _post_json(
+                client=client, target=agent_target, path="runs",
+                payload={
+                    "agent_id": target_id, "agent_config_id": target_config_id,
+                    "session_id": payload.session_id, "input_text": payload.message_text,
+                    "input_json": {"source": "session", "run_request_id": run_request_id},
+                }, headers=headers)
+            agent_run_id = _get_string(agent_run, "id")
+
+            stream_url = _target_url(agent_target, f"runs/{agent_run_id}/execute-stream")
+            async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
+                if not resp.is_success:
+                    error_message = await _read_stream_error(resp)
+                else:
+                    async for ev_name, ev_data in _parse_sse(resp):
+                        data = json.loads(ev_data)
+                        yield _sse(ev_name, data)
+                        if ev_name == "agent.run.delta" and isinstance(data.get("text"), str):
+                            output_text += data["text"]
+                        elif ev_name == "agent.run.completed":
+                            run_data = data.get("run", data)
+                            if not output_text and isinstance(run_data.get("output_text"), str):
+                                output_text = run_data["output_text"]
+                        elif ev_name == "agent.run.failed":
+                            error_message = data.get("error_message", "Agent execution failed")
+                            if not isinstance(error_message, str):
+                                error_message = "Agent execution failed"
+        else:
+            team_run = await _post_json(
+                client=client, target=team_target, path="runs",
+                payload={
+                    "team_id": target_id, "team_config_id": target_config_id,
+                    "session_id": payload.session_id, "input_text": payload.message_text,
+                    "input_json": {"source": "session", "run_request_id": run_request_id},
+                    "enqueue": True,
+                }, headers=headers)
+            team_run_id = _get_string(team_run, "id")
+
+            stream_url = _target_url(team_target, f"runs/{team_run_id}/execute-stream")
+            async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
+                if not resp.is_success:
+                    error_message = await _read_stream_error(resp)
+                else:
+                    async for ev_name, ev_data in _parse_sse(resp):
+                        data = json.loads(ev_data)
+                        yield _sse(ev_name, data)
+                        if ev_name == "team.run.delta" and isinstance(data.get("text"), str):
+                            output_text += data["text"]
+                        elif ev_name == "team.run.completed":
+                            run_data = data.get("run", data)
+                            if not output_text and isinstance(run_data.get("output_text"), str):
+                                output_text = run_data["output_text"]
+                        elif ev_name == "team.run.failed":
+                            error_message = data.get("error_message", "Team execution failed")
+                            if not isinstance(error_message, str):
+                                error_message = "Team execution failed"
+
+        request_status = "failed" if error_message else "completed"
+        assistant_message_id: str | None = None
+        if output_text:
+            assistant_message = await _post_json(
+                client=client, target=session_target, path="messages",
+                payload={
+                    "session_id": payload.session_id, "turn_id": run_request_id,
+                    "role": "assistant", "content_type": "text",
+                    "content_text": output_text, "content_json": {},
+                }, headers=headers)
+            assistant_message_id = _get_string(assistant_message, "id")
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload, "user_message_id": user_message_id,
+                    "assistant_message_id": assistant_message_id,
+                    "agent_run_id": agent_run_id, "team_run_id": team_run_id,
+                    "output_text": output_text, "error_message": error_message,
+                },
+            }, headers=headers)
+
+        yield _sse("session.execute.completed", {
+            "run_request_id": run_request_id, "request_status": request_status,
+            "assistant_message_id": assistant_message_id, "output_text": output_text,
+            "error_message": error_message,
+        })
+
+    except HTTPException as exc:
+        detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+        yield _sse("session.execute.failed", {"error_message": detail})
+    except Exception as exc:
+        yield _sse("session.execute.failed", {"error_message": str(exc)})
+    finally:
+        await client.aclose()
+
+
+# ── Application Admin Routes ─────────────────────────────────────────────────
+
+
+@router.post("/gateway/apps", response_model=AppResponse)
+def create_app(payload: AppCreateRequest, db: DbSession) -> AppResponse:
+    existing = AppDefinitionRepository(db).get_by_code(code=payload.code)
+    if existing is not None:
+        raise HTTPException(status_code=409, detail=f"app code already exists: {payload.code}")
+    entity = AppDefinitionRepository(db).create(
+        code=payload.code,
+        name=payload.name,
+        description=payload.description,
+        target_type=payload.target_type,
+        target_id=payload.target_id,
+        owner_user_id=payload.owner_user_id,
+        settings_json=payload.settings_json)
+    return AppResponse.from_entity(entity)
+
+
+@router.post("/gateway/apps/list", response_model=list[AppResponse])
+def list_apps(payload: AppListRequest, db: DbSession) -> list[AppResponse]:
+    return [AppResponse.from_entity(e) for e in AppDefinitionRepository(db).list_all()]
+
+
+@router.post("/gateway/apps/detail", response_model=AppResponse)
+def get_app_detail(payload: AppDetailRequest, db: DbSession) -> AppResponse:
+    entity = AppDefinitionRepository(db).get_by_id(app_id=payload.app_id)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"app not found: {payload.app_id}")
+    return AppResponse.from_entity(entity)
+
+
+@router.post("/gateway/apps/update", response_model=AppResponse)
+def update_app(payload: AppUpdateRequest, db: DbSession) -> AppResponse:
+    entity = AppDefinitionRepository(db).update(
+        app_id=payload.app_id,
+        name=payload.name,
+        description=payload.description,
+        target_type=payload.target_type,
+        target_id=payload.target_id,
+        settings_json=payload.settings_json)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"app not found: {payload.app_id}")
+    return AppResponse.from_entity(entity)
+
+
+@router.post("/gateway/apps/status", response_model=AppResponse)
+def update_app_status(payload: AppStatusUpdateRequest, db: DbSession) -> AppResponse:
+    entity = AppDefinitionRepository(db).update_status(app_id=payload.app_id, status=payload.status)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"app not found: {payload.app_id}")
+    return AppResponse.from_entity(entity)
+
+
+@router.post("/gateway/apps/{app_id}/api-keys", response_model=AppApiKeyCreateResponse)
+def create_app_api_key(app_id: str, payload: AppApiKeyCreateRequest, db: DbSession) -> AppApiKeyCreateResponse:
+    app_entity = AppDefinitionRepository(db).get_by_id(app_id=app_id)
+    if app_entity is None:
+        raise HTTPException(status_code=404, detail=f"app not found: {app_id}")
+    api_key = generate_api_key()
+    entity = AppApiKeyRepository(db).create(
+        app_id=app_id,
+        name=payload.name,
+        key_prefix=get_api_key_prefix(api_key),
+        key_hash=hash_api_key(api_key),
+        scopes=payload.scopes,
+        expires_time=payload.expires_time)
+    return AppApiKeyCreateResponse(
+        id=entity.id,
+        app_id=entity.app_id,
+        name=entity.name,
+        key_prefix=entity.key_prefix,
+        api_key=api_key,
+        status=entity.status,
+        scopes=entity.scopes,
+        expires_time=entity.expires_time,
+        created_time=entity.created_time)
+
+
+@router.post("/gateway/apps/{app_id}/api-keys/list", response_model=list[AppApiKeyResponse])
+def list_app_api_keys(app_id: str, payload: AppApiKeyListRequest, db: DbSession) -> list[AppApiKeyResponse]:
+    return [AppApiKeyResponse.from_entity(e) for e in AppApiKeyRepository(db).list_by_app(app_id=app_id)]
+
+
+@router.post("/gateway/apps/{app_id}/api-keys/status", response_model=AppApiKeyResponse)
+def update_app_api_key_status(app_id: str, payload: AppApiKeyStatusUpdateRequest, db: DbSession) -> AppApiKeyResponse:
+    entity = AppApiKeyRepository(db).update_status(api_key_id=payload.api_key_id, status=payload.status)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"api key not found: {payload.api_key_id}")
+    return AppApiKeyResponse.from_entity(entity)
+
+
+@router.post("/gateway/apps/{app_id}/audits", response_model=list[AppInvocationAuditResponse])
+def list_app_audits(app_id: str, payload: AppAuditListRequest, db: DbSession) -> list[AppInvocationAuditResponse]:
+    return [
+        AppInvocationAuditResponse.from_entity(e)
+        for e in AppInvocationAuditRepository(db).list_by_app(app_id=app_id, limit=payload.limit)
+    ]
+
+
+# ── OpenAPI External Invocation ──────────────────────────────────────────────
+
+
+def _authenticate_app_api_key(request: Request, db: Session):
+    settings = ApiGatewaySettings()
+    token: str | None = None
+    authorization = request.headers.get("authorization")
+    if authorization:
+        scheme, _, t = authorization.partition(" ")
+        if scheme.lower() == "bearer" and t.strip():
+            token = t.strip()
+    if token is None:
+        token = request.headers.get(settings.api_key_header_name)
+    if not token:
+        raise HTTPException(status_code=401, detail="missing bearer token or api key")
+
+    key_hash = hash_api_key(token)
+    key_entity = AppApiKeyRepository(db).get_active_by_hash(key_hash=key_hash)
+    if key_entity is None:
+        raise HTTPException(status_code=401, detail="invalid api key")
+    if key_entity.expires_time is not None and key_entity.expires_time <= datetime.utcnow():
+        raise HTTPException(status_code=401, detail="api key expired")
+
+    app_entity = AppDefinitionRepository(db).get_by_id(app_id=key_entity.app_id)
+    if app_entity is None:
+        raise HTTPException(status_code=401, detail="app not found")
+    if app_entity.status != "published":
+        raise HTTPException(status_code=403, detail=f"app is {app_entity.status}, not published")
+
+    AppApiKeyRepository(db).touch_last_used_time(api_key_id=key_entity.id)
+    return key_entity, app_entity
+
+
+@router.post("/gateway/openapi/apps/{app_code}/chat", response_model=OpenApiChatResponse)
+async def openapi_chat(app_code: str, payload: OpenApiChatRequest, request: Request, db: DbSession):
+    start = perf_counter()
+    request_id = str(uuid4())
+    key_entity, app_entity = _authenticate_app_api_key(request, db)
+    if app_entity.code != app_code:
+        raise HTTPException(status_code=403, detail="api key does not belong to this app")
+
+    targets = build_proxy_targets(ApiGatewaySettings())
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    team_target = targets["team-service"]
+    headers = _build_internal_headers(request, ApiGatewaySettings())
+
+    async with httpx_client(ApiGatewaySettings().proxy_timeout_seconds) as client:
+        session_id = payload.session_id
+        if not session_id:
+            session_data = await _post_json(
+                client=client, target=session_target, path="",
+                payload={
+                    "app_id": app_entity.id,
+                    "user_id": payload.user_id or "openapi",
+                    "channel_type": "openapi",
+                    "runtime_target_type": app_entity.target_type,
+                    "runtime_target_id": app_entity.target_id,
+                }, headers=headers)
+            session_id = _get_string(session_data, "id")
+
+        run_request_payload = {
+            "target_type": app_entity.target_type,
+            "target_id": app_entity.target_id,
+            "mode": "production",
+            "input_text": payload.message,
+        }
+        run_request = await _post_json(
+            client=client, target=session_target, path="run-requests",
+            payload={
+                "session_id": session_id,
+                "app_config_id": app_entity.target_id,
+                "workflow_config_id": app_entity.target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            }, headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client, target=session_target, path="messages",
+            payload={
+                "session_id": session_id, "turn_id": run_request_id,
+                "role": "user", "content_type": "text",
+                "content_text": payload.message, "content_json": {},
+            }, headers=headers)
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": "running",
+                "request_payload_json": {**run_request_payload, "user_message_id": _get_string(user_message, "id")},
+            }, headers=headers)
+
+        output_text: str | None = None
+        error_message: str | None = None
+        request_status = "completed"
+
+        try:
+            if app_entity.target_type == "agent":
+                agent_run = await _post_json(
+                    client=client, target=agent_target, path="runs",
+                    payload={
+                        "agent_id": app_entity.target_id,
+                        "session_id": session_id,
+                        "input_text": payload.message,
+                        "input_json": {"source": "openapi", "run_request_id": run_request_id},
+                    }, headers=headers)
+                agent_run_id = _get_string(agent_run, "id")
+                execute_result = await _post_json(
+                    client=client, target=agent_target, path="runs/execute",
+                    payload={"agent_run_id": agent_run_id, "dry_run": False}, headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+            else:
+                team_run = await _post_json(
+                    client=client, target=team_target, path="runs",
+                    payload={
+                        "team_id": app_entity.target_id,
+                        "session_id": session_id,
+                        "input_text": payload.message,
+                        "input_json": {"source": "openapi", "run_request_id": run_request_id},
+                        "enqueue": True,
+                    }, headers=headers)
+                team_run_id = _get_string(team_run, "id")
+                execute_result = await _post_json(
+                    client=client, target=team_target, path=f"runs/{team_run_id}/execute",
+                    payload={"dry_run": False}, headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+
+            if error_message:
+                request_status = "failed"
+
+            if output_text:
+                await _post_json(
+                    client=client, target=session_target, path="messages",
+                    payload={
+                        "session_id": session_id, "turn_id": run_request_id,
+                        "role": "assistant", "content_type": "text",
+                        "content_text": output_text, "content_json": {},
+                    }, headers=headers)
+        except HTTPException as exc:
+            request_status = "failed"
+            error_message = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload,
+                    "user_message_id": _get_string(user_message, "id"),
+                    "output_text": output_text, "error_message": error_message,
+                },
+            }, headers=headers)
+
+    duration_ms = int((perf_counter() - start) * 1000)
+    AppInvocationAuditRepository(db).create(
+        app_id=app_entity.id,
+        api_key_prefix=key_entity.key_prefix,
+        request_id=request_id,
+        session_id=session_id,
+        run_request_id=run_request_id,
+        target_type=app_entity.target_type,
+        target_id=app_entity.target_id,
+        invoke_type="sync",
+        status=request_status,
+        duration_ms=duration_ms,
+        error_message=error_message,
+        client_metadata_json=json.dumps(payload.metadata) if payload.metadata else None)
+
+    return OpenApiChatResponse(
+        request_id=request_id,
+        app_code=app_entity.code,
+        session_id=session_id,
+        run_request_id=run_request_id,
+        target_type=app_entity.target_type,
+        target_id=app_entity.target_id,
+        status=request_status,
+        output_text=output_text,
+        error=error_message)
+
+
+@router.post("/gateway/openapi/apps/{app_code}/chat/stream")
+async def openapi_chat_stream(app_code: str, payload: OpenApiChatRequest, request: Request):
+    settings = ApiGatewaySettings()
+    session_factory = request.app.state.session_factory
+
+    auth_db = session_factory()
+    try:
+        key_entity, app_entity = _authenticate_app_api_key(request, auth_db)
+    except HTTPException as exc:
+        auth_db.close()
+        detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+        return StreamingResponse(
+            _single_sse("failed", {"status": "failed", "error_code": "auth_error", "error_message": detail}),
+            media_type="text/event-stream",
+            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+    finally:
+        auth_db.close()
+
+    if app_entity.code != app_code:
+        return StreamingResponse(
+            _single_sse("failed", {"status": "failed", "error_code": "forbidden", "error_message": "api key does not belong to this app"}),
+            media_type="text/event-stream",
+            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+    if app_entity.target_type != "agent":
+        return StreamingResponse(
+            _single_sse("failed", {"status": "failed", "error_code": "unsupported", "error_message": "streaming is only supported for agent targets in V0.1"}),
+            media_type="text/event-stream",
+            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+
+    return StreamingResponse(
+        _stream_openapi_chat(app_code, payload, request, key_entity, app_entity, session_factory, settings),
+        media_type="text/event-stream",
+        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+
+
+def _single_sse(event: str, data: dict) -> AsyncIterator[str]:
+    async def _gen():
+        yield _sse(event, data)
+    return _gen()
+
+
+async def _stream_openapi_chat(
+    app_code: str,
+    payload: OpenApiChatRequest,
+    request: Request,
+    key_entity,
+    app_entity,
+    session_factory,
+    settings: ApiGatewaySettings):
+    start = perf_counter()
+    request_id = str(uuid4())
+
+    targets = build_proxy_targets(settings)
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    headers = _build_internal_headers(request, settings)
+    client = httpx.AsyncClient(timeout=settings.proxy_timeout_seconds)
+
+    output_text = ""
+    error_message: str | None = None
+    session_id: str | None = None
+    run_request_id: str | None = None
+    request_status = "failed"
+
+    try:
+        session_id = payload.session_id
+        if not session_id:
+            session_data = await _post_json(
+                client=client, target=session_target, path="",
+                payload={
+                    "app_id": app_entity.id,
+                    "user_id": payload.user_id or "openapi",
+                    "channel_type": "openapi",
+                    "runtime_target_type": app_entity.target_type,
+                    "runtime_target_id": app_entity.target_id,
+                }, headers=headers)
+            session_id = _get_string(session_data, "id")
+
+        run_request_payload = {
+            "target_type": app_entity.target_type,
+            "target_id": app_entity.target_id,
+            "mode": "production",
+            "input_text": payload.message,
+        }
+        run_request = await _post_json(
+            client=client, target=session_target, path="run-requests",
+            payload={
+                "session_id": session_id,
+                "app_config_id": app_entity.target_id,
+                "workflow_config_id": app_entity.target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            }, headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client, target=session_target, path="messages",
+            payload={
+                "session_id": session_id, "turn_id": run_request_id,
+                "role": "user", "content_type": "text",
+                "content_text": payload.message, "content_json": {},
+            }, headers=headers)
+        user_message_id = _get_string(user_message, "id")
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": "running",
+                "request_payload_json": {**run_request_payload, "user_message_id": user_message_id},
+            }, headers=headers)
+
+        yield _sse("started", {
+            "request_id": request_id,
+            "session_id": session_id,
+            "run_request_id": run_request_id})
+
+        agent_run = await _post_json(
+            client=client, target=agent_target, path="runs",
+            payload={
+                "agent_id": app_entity.target_id,
+                "session_id": session_id,
+                "input_text": payload.message,
+                "input_json": {"source": "openapi", "run_request_id": run_request_id},
+            }, headers=headers)
+        agent_run_id = _get_string(agent_run, "id")
+
+        stream_url = _target_url(agent_target, f"runs/{agent_run_id}/execute-stream")
+        async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
+            if not resp.is_success:
+                error_message = await _read_stream_error(resp)
+            else:
+                async for ev_name, ev_data in _parse_sse(resp):
+                    data = json.loads(ev_data)
+                    if ev_name == "agent.run.delta":
+                        text_chunk = data.get("text", "")
+                        yield _sse("delta", {"text": text_chunk})
+                        if isinstance(text_chunk, str):
+                            output_text += text_chunk
+                    elif ev_name == "agent.run.completed":
+                        run_data = data.get("run", data)
+                        final_text = _get_optional_string(run_data, "output_text")
+                        if not output_text and final_text:
+                            output_text = final_text
+                        yield _sse("completed", {"status": "completed", "output_text": output_text})
+                    elif ev_name == "agent.run.failed":
+                        msg = data.get("error_message", "Agent execution failed")
+                        if not isinstance(msg, str):
+                            msg = "Agent execution failed"
+                        error_message = msg
+                        yield _sse("failed", {"status": "failed", "error_code": "agent_error", "error_message": msg})
+                    else:
+                        yield _sse(ev_name, data)
+
+        request_status = "failed" if error_message else "completed"
+
+        if output_text:
+            await _post_json(
+                client=client, target=session_target, path="messages",
+                payload={
+                    "session_id": session_id, "turn_id": run_request_id,
+                    "role": "assistant", "content_type": "text",
+                    "content_text": output_text, "content_json": {},
+                }, headers=headers)
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload, "user_message_id": user_message_id,
+                    "output_text": output_text, "error_message": error_message,
+                },
+            }, headers=headers)
+
+    except HTTPException as exc:
+        detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+        yield _sse("failed", {"status": "failed", "error_code": "gateway_error", "error_message": detail})
+    except Exception as exc:
+        yield _sse("failed", {"status": "failed", "error_code": "internal_error", "error_message": str(exc)})
+    finally:
+        await client.aclose()
+        duration_ms = int((perf_counter() - start) * 1000)
+        audit_db = session_factory()
+        try:
+            AppInvocationAuditRepository(audit_db).create(
+                app_id=app_entity.id,
+                api_key_prefix=key_entity.key_prefix,
+                request_id=request_id,
+                session_id=session_id,
+                run_request_id=run_request_id,
+                target_type=app_entity.target_type,
+                target_id=app_entity.target_id,
+                invoke_type="stream",
+                status=request_status,
+                duration_ms=duration_ms,
+                error_message=error_message,
+                client_metadata_json=json.dumps(payload.metadata) if payload.metadata else None)
+        except Exception:
+            pass
+        finally:
+            audit_db.close()
+
+
 @router.api_route(
 @router.api_route(
     "/gateway/sessions",
     "/gateway/sessions",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -477,3 +1382,126 @@ async def proxy_code_runner_service(
         request=request,
         request=request,
         target=build_proxy_targets(settings)["code-runner-service"],
         target=build_proxy_targets(settings)["code-runner-service"],
         path=path)
         path=path)
+
+
+def _build_internal_headers(request: Request, settings: ApiGatewaySettings) -> dict[str, str]:
+    headers = build_internal_service_headers(settings)
+    authorization = request.headers.get("authorization")
+    user_id = request.headers.get("x-user-id")
+    if authorization:
+        headers["authorization"] = authorization
+    if user_id:
+        headers["x-user-id"] = user_id
+    return headers
+
+
+def httpx_client(timeout_seconds: float) -> httpx.AsyncClient:
+    return httpx.AsyncClient(timeout=timeout_seconds)
+
+
+async def _post_json(
+    *,
+    client: httpx.AsyncClient,
+    target: ProxyTarget,
+    path: str,
+    payload: dict[str, object],
+    headers: dict[str, str]) -> dict[str, object]:
+    url = _target_url(target, path)
+    try:
+        response = await client.post(url, headers=headers, json=payload)
+    except httpx.HTTPError as exc:
+        raise HTTPException(status_code=502, detail=f"{target.service_name} request failed: {exc}") from exc
+    if not response.is_success:
+        raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
+    data = response.json()
+    if not isinstance(data, dict):
+        raise HTTPException(status_code=502, detail=f"{target.service_name} returned unexpected response")
+    return data
+
+
+def _target_url(target: ProxyTarget, path: str) -> str:
+    normalized_path = path.strip("/")
+    if normalized_path:
+        return f"{target.base_url.rstrip('/')}{target.path_prefix}/{normalized_path}"
+    return f"{target.base_url.rstrip('/')}{target.path_prefix}"
+
+
+def _error_detail(response: httpx.Response) -> str:
+    try:
+        payload = response.json()
+    except ValueError:
+        return response.text or f"downstream request failed with {response.status_code}"
+    if isinstance(payload, dict):
+        detail = payload.get("detail")
+        if isinstance(detail, str):
+            return detail
+        error = payload.get("error")
+        if isinstance(error, dict):
+            message = error.get("message")
+            if isinstance(message, str):
+                return message
+    return response.text or f"downstream request failed with {response.status_code}"
+
+
+def _get_string(payload: dict[str, object], key: str) -> str:
+    value = payload.get(key)
+    if not isinstance(value, str) or not value:
+        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+    return value
+
+
+def _get_optional_string(payload: dict[str, object], key: str) -> str | None:
+    value = payload.get(key)
+    return value if isinstance(value, str) and value else None
+
+
+def _get_dict(payload: dict[str, object], key: str) -> dict[str, object]:
+    value = payload.get(key)
+    if not isinstance(value, dict):
+        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+    return value
+
+
+def _resolve_output_text(run_payload: dict[str, object]) -> str | None:
+    output_text = _get_optional_string(run_payload, "output_text")
+    if output_text:
+        return output_text
+    output_json = run_payload.get("output_json")
+    if isinstance(output_json, dict) and output_json:
+        return json.dumps(output_json, ensure_ascii=False)
+    return None
+
+
+def _sse(event: str, data: dict) -> str:
+    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+
+async def _parse_sse(response: httpx.Response):
+    current_event = "message"
+    current_data = ""
+    async for line in response.aiter_lines():
+        if line.startswith("event:"):
+            current_event = line[6:].strip()
+        elif line.startswith("data:"):
+            current_data = line[5:].strip()
+        elif line == "":
+            if current_data:
+                yield current_event, current_data
+            current_event = "message"
+            current_data = ""
+    if current_data:
+        yield current_event, current_data
+
+
+async def _read_stream_error(response: httpx.Response) -> str:
+    body = await response.aread()
+    text = body.decode(errors="replace")
+    try:
+        data = json.loads(text)
+        if isinstance(data, dict):
+            detail = data.get("detail")
+            if isinstance(detail, str):
+                return detail
+    except (ValueError, UnicodeDecodeError):
+        pass
+    return text or f"downstream error {response.status_code}"

+ 11 - 1
services/api-gateway/app/db/models/__init__.py

@@ -1,6 +1,16 @@
 from core_db import Base
 from core_db import Base
 
 
 from .api_key import ApiKey
 from .api_key import ApiKey
+from .app_api_key import AppApiKey
+from .app_definition import AppDefinition
+from .app_invocation_audit import AppInvocationAudit
 from .gateway_request_audit import GatewayRequestAudit
 from .gateway_request_audit import GatewayRequestAudit
 
 
-__all__ = ["ApiKey", "Base", "GatewayRequestAudit"]
+__all__ = [
+    "ApiKey",
+    "AppApiKey",
+    "AppDefinition",
+    "AppInvocationAudit",
+    "Base",
+    "GatewayRequestAudit",
+]

+ 18 - 0
services/api-gateway/app/db/models/app_api_key.py

@@ -0,0 +1,18 @@
+from datetime import datetime
+
+from core_db import AuditMixin, Base, EntityMixin
+from sqlalchemy import DateTime, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class AppApiKey(EntityMixin, AuditMixin, Base):
+    __tablename__ = "app_api_key"
+
+    app_id: Mapped[str] = mapped_column(String(36), index=True)
+    name: Mapped[str] = mapped_column(String(128))
+    key_prefix: Mapped[str] = mapped_column(String(16), index=True)
+    key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
+    status: Mapped[str] = mapped_column(String(32), default="active", index=True)
+    scopes: Mapped[str | None] = mapped_column(Text, nullable=True)
+    expires_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_used_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 16 - 0
services/api-gateway/app/db/models/app_definition.py

@@ -0,0 +1,16 @@
+from core_db import AuditMixin, Base, EntityMixin
+from sqlalchemy import String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class AppDefinition(EntityMixin, AuditMixin, Base):
+    __tablename__ = "app_definition"
+
+    code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+    name: Mapped[str] = mapped_column(String(128))
+    description: Mapped[str | None] = mapped_column(Text, nullable=True)
+    status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
+    target_type: Mapped[str] = mapped_column(String(32))
+    target_id: Mapped[str] = mapped_column(String(36))
+    owner_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    settings_json: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 21 - 0
services/api-gateway/app/db/models/app_invocation_audit.py

@@ -0,0 +1,21 @@
+from core_db import AuditMixin, Base, EntityMixin
+from sqlalchemy import Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class AppInvocationAudit(EntityMixin, AuditMixin, Base):
+    __tablename__ = "app_invocation_audit"
+
+    app_id: Mapped[str] = mapped_column(String(36), index=True)
+    api_key_prefix: Mapped[str | None] = mapped_column(String(16), nullable=True)
+    request_id: Mapped[str] = mapped_column(String(64), index=True)
+    session_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    run_request_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    target_type: Mapped[str] = mapped_column(String(32))
+    target_id: Mapped[str] = mapped_column(String(36))
+    invoke_type: Mapped[str] = mapped_column(String(16))
+    status: Mapped[str] = mapped_column(String(32), index=True)
+    duration_ms: Mapped[int] = mapped_column(Integer)
+    error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    client_metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 190 - 1
services/api-gateway/app/domain/repositories.py

@@ -3,7 +3,7 @@ from datetime import datetime
 from sqlalchemy import case, func, select
 from sqlalchemy import case, func, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
-from app.db.models import ApiKey, GatewayRequestAudit
+from app.db.models import ApiKey, AppApiKey, AppDefinition, AppInvocationAudit, GatewayRequestAudit
 
 
 
 
 class GatewayRequestAuditRepository:
 class GatewayRequestAuditRepository:
@@ -153,3 +153,192 @@ class ApiKeyRepository:
         self.db.commit()
         self.db.commit()
         self.db.refresh(entity)
         self.db.refresh(entity)
         return entity
         return entity
+
+
+class AppDefinitionRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        code: str,
+        name: str,
+        target_type: str,
+        target_id: str,
+        description: str | None = None,
+        owner_user_id: str | None = None,
+        settings_json: str | None = None) -> AppDefinition:
+        entity = AppDefinition(
+            code=code,
+            name=name,
+            description=description,
+            status="draft",
+            target_type=target_type,
+            target_id=target_id,
+            owner_user_id=owner_user_id,
+            settings_json=settings_json)
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def get_by_id(self, *, app_id: str) -> AppDefinition | None:
+        stmt = select(AppDefinition).where(AppDefinition.id == app_id).limit(1)
+        return self.db.scalar(stmt)
+
+    def get_by_code(self, *, code: str) -> AppDefinition | None:
+        stmt = select(AppDefinition).where(AppDefinition.code == code).limit(1)
+        return self.db.scalar(stmt)
+
+    def list_all(self) -> list[AppDefinition]:
+        stmt = select(AppDefinition).order_by(AppDefinition.created_time.desc())
+        return list(self.db.scalars(stmt))
+
+    def update(
+        self,
+        *,
+        app_id: str,
+        name: str | None = None,
+        description: str | None = None,
+        target_type: str | None = None,
+        target_id: str | None = None,
+        settings_json: str | None = None) -> AppDefinition | None:
+        entity = self.get_by_id(app_id=app_id)
+        if entity is None:
+            return None
+        if name is not None:
+            entity.name = name
+        if description is not None:
+            entity.description = description
+        if target_type is not None:
+            entity.target_type = target_type
+        if target_id is not None:
+            entity.target_id = target_id
+        if settings_json is not None:
+            entity.settings_json = settings_json
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def update_status(self, *, app_id: str, status: str) -> AppDefinition | None:
+        entity = self.get_by_id(app_id=app_id)
+        if entity is None:
+            return None
+        entity.status = status
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+
+class AppApiKeyRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        app_id: str,
+        name: str,
+        key_prefix: str,
+        key_hash: str,
+        scopes: str | None,
+        expires_time: datetime | None) -> AppApiKey:
+        entity = AppApiKey(
+            app_id=app_id,
+            name=name,
+            key_prefix=key_prefix,
+            key_hash=key_hash,
+            status="active",
+            scopes=scopes,
+            expires_time=expires_time)
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_app(self, *, app_id: str) -> list[AppApiKey]:
+        stmt = (
+            select(AppApiKey)
+            .where(AppApiKey.app_id == app_id)
+            .order_by(AppApiKey.created_time.desc())
+        )
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, api_key_id: str) -> AppApiKey | None:
+        stmt = select(AppApiKey).where(AppApiKey.id == api_key_id).limit(1)
+        return self.db.scalar(stmt)
+
+    def get_active_by_hash(self, *, key_hash: str) -> AppApiKey | None:
+        stmt = (
+            select(AppApiKey)
+            .where(AppApiKey.key_hash == key_hash)
+            .where(AppApiKey.status == "active")
+            .limit(1)
+        )
+        return self.db.scalar(stmt)
+
+    def touch_last_used_time(self, *, api_key_id: str) -> None:
+        entity = self.db.get(AppApiKey, api_key_id)
+        if entity is None:
+            return
+        entity.last_used_time = datetime.utcnow()
+        self.db.commit()
+
+    def update_status(self, *, api_key_id: str, status: str) -> AppApiKey | None:
+        entity = self.get_by_id(api_key_id=api_key_id)
+        if entity is None:
+            return None
+        entity.status = status
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+
+class AppInvocationAuditRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        app_id: str,
+        request_id: str,
+        target_type: str,
+        target_id: str,
+        invoke_type: str,
+        status: str,
+        duration_ms: int,
+        api_key_prefix: str | None = None,
+        session_id: str | None = None,
+        run_request_id: str | None = None,
+        error_code: str | None = None,
+        error_message: str | None = None,
+        client_metadata_json: str | None = None) -> AppInvocationAudit:
+        entity = AppInvocationAudit(
+            app_id=app_id,
+            api_key_prefix=api_key_prefix,
+            request_id=request_id,
+            session_id=session_id,
+            run_request_id=run_request_id,
+            target_type=target_type,
+            target_id=target_id,
+            invoke_type=invoke_type,
+            status=status,
+            duration_ms=duration_ms,
+            error_code=error_code,
+            error_message=error_message,
+            client_metadata_json=client_metadata_json)
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_app(self, *, app_id: str, limit: int = 100) -> list[AppInvocationAudit]:
+        stmt = (
+            select(AppInvocationAudit)
+            .where(AppInvocationAudit.app_id == app_id)
+            .order_by(AppInvocationAudit.created_time.desc())
+            .limit(limit)
+        )
+        return list(self.db.scalars(stmt))

+ 2 - 0
services/api-gateway/app/infrastructure/request_context.py

@@ -111,6 +111,8 @@ def authenticate_gateway_request(request: Request) -> Response | None:
         return None
         return None
     if request.url.path in {"/gateway/services/health"}:
     if request.url.path in {"/gateway/services/health"}:
         return None
         return None
+    if request.url.path.startswith("/gateway/openapi/"):
+        return None
     if is_auth_login_request(request):
     if is_auth_login_request(request):
         return None
         return None
 
 

+ 156 - 1
services/api-gateway/app/schemas/gateway.py

@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Literal
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from app.db.models import ApiKey, GatewayRequestAudit
+    from app.db.models import ApiKey, AppApiKey, AppDefinition, AppInvocationAudit, GatewayRequestAudit
 
 
 
 
 class DownstreamServiceHealth(BaseModel):
 class DownstreamServiceHealth(BaseModel):
@@ -99,3 +99,158 @@ class ApiKeyStatusUpdateRequest(BaseModel):
 
 
 class ApiKeyStatusPostRequest(ApiKeyStatusUpdateRequest):
 class ApiKeyStatusPostRequest(ApiKeyStatusUpdateRequest):
     api_key_id: str
     api_key_id: str
+
+
+# ── Application ──────────────────────────────────────────────────────────────
+
+AppStatus = Literal["draft", "published", "disabled"]
+AppTargetType = Literal["agent", "team"]
+
+
+class AppCreateRequest(BaseModel):
+    code: str
+    name: str
+    description: str | None = None
+    target_type: AppTargetType
+    target_id: str
+    owner_user_id: str | None = None
+    settings_json: str | None = None
+
+
+class AppListRequest(BaseModel):
+    pass
+
+
+class AppDetailRequest(BaseModel):
+    app_id: str
+
+
+class AppUpdateRequest(BaseModel):
+    app_id: str
+    name: str | None = None
+    description: str | None = None
+    target_type: AppTargetType | None = None
+    target_id: str | None = None
+    settings_json: str | None = None
+
+
+class AppStatusUpdateRequest(BaseModel):
+    app_id: str
+    status: AppStatus
+
+
+class AppResponse(BaseModel):
+    id: str
+    code: str
+    name: str
+    description: str | None = None
+    status: str
+    target_type: str
+    target_id: str
+    owner_user_id: str | None = None
+    settings_json: str | None = None
+    created_time: datetime
+    updated_time: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "AppDefinition") -> "AppResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+# ── App API Key ──────────────────────────────────────────────────────────────
+
+AppApiKeyStatus = Literal["active", "disabled", "revoked"]
+
+
+class AppApiKeyCreateRequest(BaseModel):
+    name: str
+    scopes: str | None = None
+    expires_time: datetime | None = None
+
+
+class AppApiKeyCreateResponse(BaseModel):
+    id: str
+    app_id: str
+    name: str
+    key_prefix: str
+    api_key: str
+    status: str
+    scopes: str | None = None
+    expires_time: datetime | None = None
+    created_time: datetime
+
+
+class AppApiKeyListRequest(BaseModel):
+    pass
+
+
+class AppApiKeyResponse(BaseModel):
+    id: str
+    app_id: str
+    name: str
+    key_prefix: str
+    status: str
+    scopes: str | None = None
+    expires_time: datetime | None = None
+    last_used_time: datetime | None = None
+    created_time: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "AppApiKey") -> "AppApiKeyResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+class AppApiKeyStatusUpdateRequest(BaseModel):
+    api_key_id: str
+    status: AppApiKeyStatus
+
+
+# ── App Invocation Audit ─────────────────────────────────────────────────────
+
+class AppAuditListRequest(BaseModel):
+    limit: int = 100
+
+
+class AppInvocationAuditResponse(BaseModel):
+    id: str
+    app_id: str
+    api_key_prefix: str | None = None
+    request_id: str
+    session_id: str | None = None
+    run_request_id: str | None = None
+    target_type: str
+    target_id: str
+    invoke_type: str
+    status: str
+    duration_ms: int
+    error_code: str | None = None
+    error_message: str | None = None
+    client_metadata_json: str | None = None
+    created_time: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "AppInvocationAudit") -> "AppInvocationAuditResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+# ── OpenAPI External Invocation ──────────────────────────────────────────────
+
+class OpenApiChatRequest(BaseModel):
+    user_id: str | None = None
+    session_id: str | None = None
+    message: str
+    inputs: dict[str, object] | None = None
+    metadata: dict[str, object] | None = None
+
+
+class OpenApiChatResponse(BaseModel):
+    request_id: str
+    app_code: str
+    session_id: str
+    run_request_id: str
+    target_type: str
+    target_id: str
+    status: str
+    output_text: str | None = None
+    output_json: dict[str, object] | None = None
+    error: str | None = None

+ 34 - 0
services/knowledge-service/alembic/versions/20260514_0003_resize_embedding_vector.py

@@ -0,0 +1,34 @@
+"""resize embedding vector from 32 to 1536 dimensions
+
+Revision ID: 20260514_0003_embedding
+Revises: 20260429_9001_remove_version_columns
+Create Date: 2026-05-14 00:00:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260514_0003_embedding"
+down_revision: str | None = "20260429_9001_remove_version_columns"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.execute("ALTER TABLE knowledge_chunk ALTER COLUMN embedding_vector TYPE vector(1536)")
+    op.execute("DROP INDEX IF EXISTS ix_knowledge_chunk_embedding_vector")
+    op.execute(
+        "CREATE INDEX ix_knowledge_chunk_embedding_vector "
+        "ON knowledge_chunk USING hnsw (embedding_vector vector_cosine_ops)"
+    )
+
+
+def downgrade() -> None:
+    op.execute("DROP INDEX IF EXISTS ix_knowledge_chunk_embedding_vector")
+    op.execute("ALTER TABLE knowledge_chunk ALTER COLUMN embedding_vector TYPE vector(32)")
+    op.execute(
+        "CREATE INDEX ix_knowledge_chunk_embedding_vector "
+        "ON knowledge_chunk USING hnsw (embedding_vector vector_cosine_ops)"
+    )

+ 237 - 0
services/knowledge-service/app/application/chunking.py

@@ -0,0 +1,237 @@
+"""Structure-aware document chunking."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+from core_shared import JSONValue
+
+from app.application.retrieval import tokenize
+
+_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
+_CODE_BLOCK_RE = re.compile(r"```[\s\S]*?```")
+_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?。!?])\s+")
+_PARAGRAPH_SPLIT_RE = re.compile(r"\n{2,}")
+
+
+@dataclass(frozen=True)
+class ChunkPayload:
+    chunk_index: int
+    content_text: str
+    token_count: int
+    metadata_json: dict[str, JSONValue]
+
+
+def chunk_document(
+    *,
+    content_text: str,
+    source_type: str,
+    chunk_size: int,
+    chunk_overlap: int,
+) -> list[dict[str, JSONValue]]:
+    """Dispatch to the appropriate chunker based on source_type."""
+    normalized = source_type.strip().lower()
+    text_for_chunking = raw_content or content_text
+    if normalized in {"markdown", "md"}:
+        chunks = _chunk_markdown(text_for_chunking, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
+    elif normalized == "json":
+        chunks = _chunk_json(content_text, chunk_size=chunk_size)
+    else:
+        chunks = _chunk_plain_text(content_text, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
+    return [
+        {
+            "chunk_index": c.chunk_index,
+            "content_text": c.content_text,
+            "token_count": c.token_count,
+            "metadata_json": c.metadata_json,
+        }
+        for c in chunks
+    ]
+
+
+def _chunk_markdown(content: str, *, chunk_size: int, chunk_overlap: int) -> list[ChunkPayload]:
+    sections = _split_markdown_by_headings(content)
+    chunks: list[ChunkPayload] = []
+    index = 0
+
+    for heading_path, section_text in sections:
+        section_text = section_text.strip()
+        if not section_text:
+            continue
+
+        if len(section_text) <= chunk_size:
+            chunks.append(_make_chunk(index, section_text, {"heading_path": heading_path, "chunk_type": "heading_section"}))
+            index += 1
+            continue
+
+        sub_parts = _split_markdown_section(section_text)
+        buffer = ""
+        for part_text, part_type in sub_parts:
+            if len(buffer) + len(part_text) + 1 > chunk_size and buffer:
+                chunks.append(_make_chunk(index, buffer.strip(), {"heading_path": heading_path, "chunk_type": part_type}))
+                index += 1
+                overlap_text = buffer[-chunk_overlap:] if chunk_overlap > 0 else ""
+                buffer = overlap_text + "\n" + part_text
+            else:
+                buffer = buffer + "\n" + part_text if buffer else part_text
+        if buffer.strip():
+            chunks.append(_make_chunk(index, buffer.strip(), {"heading_path": heading_path, "chunk_type": "paragraph"}))
+            index += 1
+
+    return chunks
+
+
+def _split_markdown_by_headings(content: str) -> list[tuple[list[str], str]]:
+    """Split markdown into (heading_path, section_text) tuples."""
+    positions: list[tuple[int, int, str]] = []
+    for match in _HEADING_RE.finditer(content):
+        level = len(match.group(1))
+        title = match.group(2).strip()
+        positions.append((match.start(), level, title))
+
+    if not positions:
+        return [([], content)]
+
+    sections: list[tuple[list[str], str]] = []
+    active_headings: dict[int, str] = {}
+
+    first_pos = positions[0][0]
+    if first_pos > 0:
+        preamble = content[:first_pos].strip()
+        if preamble:
+            sections.append(([], preamble))
+
+    for i, (pos, level, title) in enumerate(positions):
+        active_headings[level] = title
+        for higher in list(active_headings):
+            if higher > level:
+                del active_headings[higher]
+        path = [active_headings[l] for l in sorted(active_headings)]
+        end = positions[i + 1][0] if i + 1 < len(positions) else len(content)
+        section_text = content[pos:end]
+        section_text = re.sub(r"^#{1,6}\s+.+$", "", section_text, count=1, flags=re.MULTILINE).strip()
+        if section_text:
+            sections.append((path, section_text))
+
+    return sections
+
+
+def _split_markdown_section(text: str) -> list[tuple[str, str]]:
+    """Split a markdown section into (text, chunk_type) parts."""
+    parts: list[tuple[str, str]] = []
+    last_end = 0
+
+    for match in _CODE_BLOCK_RE.finditer(text):
+        if match.start() > last_end:
+            prose = text[last_end:match.start()].strip()
+            if prose:
+                for para in _PARAGRAPH_SPLIT_RE.split(prose):
+                    p = para.strip()
+                    if p:
+                        parts.append((p, "paragraph"))
+        code = match.group()
+        parts.append((code, "code_block"))
+        last_end = match.end()
+
+    if last_end < len(text):
+        remaining = text[last_end:].strip()
+        if remaining:
+            for para in _PARAGRAPH_SPLIT_RE.split(remaining):
+                p = para.strip()
+                if p:
+                    parts.append((p, "paragraph"))
+
+    return parts
+
+
+def _chunk_plain_text(content: str, *, chunk_size: int, chunk_overlap: int) -> list[ChunkPayload]:
+    paragraphs = _PARAGRAPH_SPLIT_RE.split(content.strip())
+    paragraphs = [p.strip() for p in paragraphs if p.strip()]
+    if not paragraphs:
+        return []
+
+    chunks: list[ChunkPayload] = []
+    buffer = ""
+    index = 0
+
+    for para in paragraphs:
+        if len(para) > chunk_size:
+            if buffer:
+                chunks.append(_make_chunk(index, buffer.strip(), {"chunk_type": "paragraph"}))
+                index += 1
+                buffer = ""
+            sentences = _split_sentences(para)
+            sent_buffer = ""
+            for sentence in sentences:
+                if len(sent_buffer) + len(sentence) + 1 > chunk_size and sent_buffer:
+                    chunks.append(_make_chunk(index, sent_buffer.strip(), {"chunk_type": "sentence"}))
+                    index += 1
+                    overlap_text = sent_buffer[-chunk_overlap:] if chunk_overlap > 0 else ""
+                    sent_buffer = overlap_text + " " + sentence
+                else:
+                    sent_buffer = sent_buffer + " " + sentence if sent_buffer else sentence
+            if sent_buffer.strip():
+                buffer = sent_buffer.strip()
+        elif len(buffer) + len(para) + 2 > chunk_size and buffer:
+            chunks.append(_make_chunk(index, buffer.strip(), {"chunk_type": "paragraph"}))
+            index += 1
+            overlap_text = buffer[-chunk_overlap:] if chunk_overlap > 0 else ""
+            buffer = overlap_text + "\n\n" + para
+        else:
+            buffer = buffer + "\n\n" + para if buffer else para
+
+    if buffer.strip():
+        chunks.append(_make_chunk(index, buffer.strip(), {"chunk_type": "paragraph"}))
+
+    return chunks
+
+
+def _chunk_json(content: str, *, chunk_size: int) -> list[ChunkPayload]:
+    import json as json_lib
+    try:
+        data = json_lib.loads(content)
+    except json_lib.JSONDecodeError:
+        return _chunk_plain_text(content, chunk_size=chunk_size, chunk_overlap=0)
+
+    if isinstance(data, dict):
+        parts: list[tuple[str, str]] = []
+        for key, value in data.items():
+            line = f"{key}: {json_lib.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value}"
+            parts.append((key, line))
+        chunks: list[ChunkPayload] = []
+        buffer = ""
+        buffer_keys: list[str] = []
+        index = 0
+        for key, line in parts:
+            if len(buffer) + len(line) + 1 > chunk_size and buffer:
+                chunks.append(_make_chunk(index, buffer.strip(), {"chunk_type": "json_keys", "key_path": buffer_keys}))
+                index += 1
+                buffer = line
+                buffer_keys = [key]
+            else:
+                buffer = buffer + "\n" + line if buffer else line
+                buffer_keys.append(key)
+        if buffer.strip():
+            chunks.append(_make_chunk(index, buffer.strip(), {"chunk_type": "json_keys", "key_path": buffer_keys}))
+        return chunks
+
+    if isinstance(data, list):
+        items_text = "\n".join(json_lib.dumps(item, ensure_ascii=False) for item in data)
+        return _chunk_plain_text(items_text, chunk_size=chunk_size, chunk_overlap=0)
+
+    return _chunk_plain_text(content, chunk_size=chunk_size, chunk_overlap=0)
+
+
+def _split_sentences(text: str) -> list[str]:
+    parts = _SENTENCE_SPLIT_RE.split(text)
+    return [p.strip() for p in parts if p.strip()]
+
+
+def _make_chunk(index: int, text: str, metadata: dict[str, JSONValue]) -> ChunkPayload:
+    return ChunkPayload(
+        chunk_index=index,
+        content_text=text,
+        token_count=len(tokenize(text)),
+        metadata_json=metadata,
+    )

+ 1 - 5
services/knowledge-service/app/application/document_parsers.py

@@ -113,13 +113,9 @@ def normalize_source_type(*, source_type: str, source_uri: str | None = None) ->
 
 
 
 
 def parse_markdown(content: str) -> str:
 def parse_markdown(content: str) -> str:
-    text = re.sub(r"```[\s\S]*?```", " ", content)
-    text = re.sub(r"`([^`]+)`", r"\1", text)
+    text = re.sub(r"`([^`]+)`", r"\1", content)
     text = re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", text)
     text = re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", text)
     text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
     text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
-    text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.MULTILINE)
-    text = re.sub(r"^\s{0,3}>\s?", "", text, flags=re.MULTILINE)
-    text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
     return normalize_text(text)
     return normalize_text(text)
 
 
 
 

+ 93 - 4
services/knowledge-service/app/application/embeddings.py

@@ -1,3 +1,4 @@
+import logging
 from dataclasses import dataclass
 from dataclasses import dataclass
 
 
 import httpx
 import httpx
@@ -5,6 +6,8 @@ import httpx
 from app.application.retrieval import build_hash_embedding
 from app.application.retrieval import build_hash_embedding
 from app.bootstrap.settings import KnowledgeServiceSettings
 from app.bootstrap.settings import KnowledgeServiceSettings
 
 
+logger = logging.getLogger(__name__)
+
 
 
 class EmbeddingProviderError(Exception):
 class EmbeddingProviderError(Exception):
     pass
     pass
@@ -22,14 +25,36 @@ class EmbeddingService:
         self.settings = settings
         self.settings = settings
 
 
     def embed_text(self, text: str) -> EmbeddingResult:
     def embed_text(self, text: str) -> EmbeddingResult:
-        if self.settings.embedding_provider == "http":
+        provider = self.settings.embedding_provider
+        if provider == "model_gateway":
+            try:
+                return self._embed_with_model_gateway(text)
+            except EmbeddingProviderError:
+                if not self.settings.embedding_fallback_to_local:
+                    raise
+                logger.warning("model_gateway embedding failed, falling back to local-hash")
+        elif provider == "http":
             try:
             try:
                 return self._embed_with_http(text)
                 return self._embed_with_http(text)
             except EmbeddingProviderError:
             except EmbeddingProviderError:
                 if not self.settings.embedding_fallback_to_local:
                 if not self.settings.embedding_fallback_to_local:
                     raise
                     raise
+                logger.warning("http embedding failed, falling back to local-hash")
         return self._embed_with_local_hash(text)
         return self._embed_with_local_hash(text)
 
 
+    def embed_texts(self, texts: list[str]) -> list[EmbeddingResult]:
+        if not texts:
+            return []
+        provider = self.settings.embedding_provider
+        if provider == "model_gateway":
+            try:
+                return self._embed_batch_with_model_gateway(texts)
+            except EmbeddingProviderError:
+                if not self.settings.embedding_fallback_to_local:
+                    raise
+                logger.warning("model_gateway batch embedding failed, falling back to local-hash")
+        return [self._embed_with_local_hash(t) for t in texts]
+
     def _embed_with_local_hash(self, text: str) -> EmbeddingResult:
     def _embed_with_local_hash(self, text: str) -> EmbeddingResult:
         return EmbeddingResult(
         return EmbeddingResult(
             embedding=build_hash_embedding(
             embedding=build_hash_embedding(
@@ -38,6 +63,51 @@ class EmbeddingService:
             model=self.settings.embedding_model,
             model=self.settings.embedding_model,
             provider="local-hash")
             provider="local-hash")
 
 
+    def _embed_with_model_gateway(self, text: str) -> EmbeddingResult:
+        url = f"{self.settings.model_gateway_service_url.rstrip('/')}/models/embeddings"
+        try:
+            with httpx.Client(timeout=self.settings.model_gateway_timeout_seconds) as client:
+                response = client.post(url, json={
+                    "model": self.settings.embedding_model,
+                    "input": text,
+                    "dimensions": self.settings.embedding_dimensions or None,
+                })
+                response.raise_for_status()
+                payload = response.json()
+        except (httpx.HTTPError, ValueError) as exc:
+            raise EmbeddingProviderError(f"model_gateway embedding failed: {exc}") from exc
+
+        embedding = _read_openai_embedding(payload)
+        if embedding is None:
+            raise EmbeddingProviderError("model_gateway response missing data[0].embedding")
+        return EmbeddingResult(
+            embedding=embedding,
+            model=self.settings.embedding_model,
+            provider="model_gateway")
+
+    def _embed_batch_with_model_gateway(self, texts: list[str]) -> list[EmbeddingResult]:
+        url = f"{self.settings.model_gateway_service_url.rstrip('/')}/models/embeddings"
+        try:
+            with httpx.Client(timeout=self.settings.model_gateway_timeout_seconds) as client:
+                response = client.post(url, json={
+                    "model": self.settings.embedding_model,
+                    "input": texts,
+                    "dimensions": self.settings.embedding_dimensions or None,
+                })
+                response.raise_for_status()
+                payload = response.json()
+        except (httpx.HTTPError, ValueError) as exc:
+            raise EmbeddingProviderError(f"model_gateway batch embedding failed: {exc}") from exc
+
+        items = _read_openai_embedding_batch(payload)
+        if len(items) != len(texts):
+            raise EmbeddingProviderError(
+                f"model_gateway returned {len(items)} embeddings, expected {len(texts)}")
+        return [
+            EmbeddingResult(embedding=emb, model=self.settings.embedding_model, provider="model_gateway")
+            for emb in items
+        ]
+
     def _embed_with_http(self, text: str) -> EmbeddingResult:
     def _embed_with_http(self, text: str) -> EmbeddingResult:
         if not self.settings.embedding_base_url:
         if not self.settings.embedding_base_url:
             raise EmbeddingProviderError("embedding_base_url is required for http provider")
             raise EmbeddingProviderError("embedding_base_url is required for http provider")
@@ -75,11 +145,30 @@ def _read_openai_embedding(payload: object) -> list[float] | None:
     first_item = data[0]
     first_item = data[0]
     if not isinstance(first_item, dict):
     if not isinstance(first_item, dict):
         return None
         return None
-    embedding = first_item.get("embedding")
-    if not isinstance(embedding, list):
+    return _extract_embedding_list(first_item.get("embedding"))
+
+
+def _read_openai_embedding_batch(payload: object) -> list[list[float]]:
+    if not isinstance(payload, dict):
+        return []
+    data = payload.get("data")
+    if not isinstance(data, list):
+        return []
+    results: list[list[float]] = []
+    for item in sorted(data, key=lambda d: d.get("index", 0) if isinstance(d, dict) else 0):
+        if not isinstance(item, dict):
+            continue
+        embedding = _extract_embedding_list(item.get("embedding"))
+        if embedding is not None:
+            results.append(embedding)
+    return results
+
+
+def _extract_embedding_list(raw: object) -> list[float] | None:
+    if not isinstance(raw, list):
         return None
         return None
     values: list[float] = []
     values: list[float] = []
-    for item in embedding:
+    for item in raw:
         if not isinstance(item, (int, float)) or isinstance(item, bool):
         if not isinstance(item, (int, float)) or isinstance(item, bool):
             return None
             return None
         values.append(float(item))
         values.append(float(item))

+ 56 - 3
services/knowledge-service/app/application/retrieval.py

@@ -7,6 +7,9 @@ from core_shared import JSONValue
 
 
 TOKEN_PATTERN = re.compile(r"[\w\u4e00-\u9fff]+", re.UNICODE)
 TOKEN_PATTERN = re.compile(r"[\w\u4e00-\u9fff]+", re.UNICODE)
 
 
+_K1 = 1.5
+_B = 0.75
+
 
 
 def split_text(text: str, *, chunk_size: int, chunk_overlap: int) -> list[str]:
 def split_text(text: str, *, chunk_size: int, chunk_overlap: int) -> list[str]:
     normalized_text = text.strip()
     normalized_text = text.strip()
@@ -55,15 +58,65 @@ def cosine_similarity(left: list[float] | None, right: list[float] | None) -> fl
 
 
 
 
 def keyword_score(query: str, text: str) -> float:
 def keyword_score(query: str, text: str) -> float:
+    """Backward-compatible wrapper around bm25_score with fallback stats."""
+    query_tokens = tokenize(query)
+    if not query_tokens:
+        return 0.0
+    text_tokens = tokenize(text)
+    if not text_tokens:
+        return 0.0
+    doc_length = len(text_tokens)
+    avg_doc_length = float(doc_length) or 1.0
+    doc_count = 1
+    text_counts = Counter(text_tokens)
+    df: dict[str, int] = {token: 1 for token in text_counts}
+    return bm25_score(query, text, avg_doc_length=avg_doc_length, doc_count=doc_count, df=df)
+
+
+def bm25_score(
+    query: str,
+    text: str,
+    *,
+    avg_doc_length: float,
+    doc_count: int,
+    df: dict[str, int],
+) -> float:
+    """Standard BM25 scoring. k1=1.5, b=0.75."""
     query_tokens = tokenize(query)
     query_tokens = tokenize(query)
     if not query_tokens:
     if not query_tokens:
         return 0.0
         return 0.0
     text_counts = Counter(tokenize(text))
     text_counts = Counter(tokenize(text))
+    doc_length = sum(text_counts.values())
     if not text_counts:
     if not text_counts:
         return 0.0
         return 0.0
-    matched = sum(1 for token in query_tokens if token in text_counts)
-    frequency = sum(text_counts.get(token, 0) for token in query_tokens)
-    return matched / len(set(query_tokens)) + min(frequency / 20.0, 1.0)
+    score = 0.0
+    for token in set(query_tokens):
+        tf = text_counts.get(token, 0)
+        if tf == 0:
+            continue
+        idf_numerator = doc_count - df.get(token, 0) + 0.5
+        idf_denominator = df.get(token, 0) + 0.5
+        if idf_denominator <= 0:
+            continue
+        idf = math.log((idf_numerator / idf_denominator) + 1.0)
+        tf_norm = (tf * (_K1 + 1)) / (tf + _K1 * (1 - _B + _B * doc_length / max(avg_doc_length, 1.0)))
+        score += idf * tf_norm
+    return max(score, 0.0)
+
+
+def compute_bm25_stats(chunk_texts: list[str]) -> tuple[float, int, dict[str, int]]:
+    """Compute average doc length, total doc count, and document frequency map for BM25."""
+    if not chunk_texts:
+        return 0.0, 0, {}
+    total_length = 0
+    df: dict[str, int] = {}
+    for text in chunk_texts:
+        tokens = set(tokenize(text))
+        total_length += len(tokens)
+        for token in tokens:
+            df[token] = df.get(token, 0) + 1
+    avg_doc_length = total_length / len(chunk_texts)
+    return avg_doc_length, len(chunk_texts), df
 
 
 
 
 def rerank_score(*, query: str, chunk_text: str, document_title: str | None = None) -> float:
 def rerank_score(*, query: str, chunk_text: str, document_title: str | None = None) -> float:

+ 28 - 10
services/knowledge-service/app/application/services.py

@@ -18,9 +18,12 @@ from app.application.document_parsers import (
     normalize_source_type,
     normalize_source_type,
     parse_document_content,
     parse_document_content,
     read_document_content_bytes)
     read_document_content_bytes)
+from app.application.chunking import chunk_document
 from app.application.embeddings import EmbeddingService
 from app.application.embeddings import EmbeddingService
 from app.application.retrieval import (
 from app.application.retrieval import (
+    bm25_score,
     build_chunk_payloads,
     build_chunk_payloads,
+    compute_bm25_stats,
     cosine_similarity,
     cosine_similarity,
     keyword_score,
     keyword_score,
     rerank_score,
     rerank_score,
@@ -774,6 +777,15 @@ class KnowledgeApplicationService:
                 knowledge_base_id=payload.knowledge_base_id)
                 knowledge_base_id=payload.knowledge_base_id)
             vector_scores_by_chunk_id = {}
             vector_scores_by_chunk_id = {}
             retrieval_mode = "hybrid"
             retrieval_mode = "hybrid"
+        # Resolve per-base retrieval weights with global fallback
+        kb = self.base_repository.get_by_id(knowledge_base_id=payload.knowledge_base_id)
+        retrieval_config = (kb.metadata_json or {}).get("retrieval_config", {}) if kb else {}
+        keyword_weight = float(retrieval_config.get("keyword_weight", self.settings.retrieval_keyword_weight))
+        vector_weight = float(retrieval_config.get("vector_weight", self.settings.retrieval_vector_weight))
+        rerank_weight = float(retrieval_config.get("rerank_weight", self.settings.retrieval_rerank_weight))
+        # Pre-compute BM25 collection stats from candidate chunks
+        chunk_texts = [chunk.content_text for chunk in chunks]
+        avg_doc_length, doc_count, df_map = compute_bm25_stats(chunk_texts)
         scored: list[tuple[KnowledgeChunk, KnowledgeDocument, float, dict[str, JSONValue]]] = []
         scored: list[tuple[KnowledgeChunk, KnowledgeDocument, float, dict[str, JSONValue]]] = []
         for chunk in chunks:
         for chunk in chunks:
             document = document_cache.get(chunk.document_id)
             document = document_cache.get(chunk.document_id)
@@ -785,7 +797,9 @@ class KnowledgeApplicationService:
                 document_cache[chunk.document_id] = document
                 document_cache[chunk.document_id] = document
             if not self._matches_filters(document=document, filters_json=payload.filters_json):
             if not self._matches_filters(document=document, filters_json=payload.filters_json):
                 continue
                 continue
-            keyword = keyword_score(payload.query, chunk.content_text)
+            keyword = bm25_score(
+                payload.query, chunk.content_text,
+                avg_doc_length=avg_doc_length, doc_count=doc_count, df=df_map)
             vector = vector_scores_by_chunk_id.get(chunk.id)
             vector = vector_scores_by_chunk_id.get(chunk.id)
             if vector is None:
             if vector is None:
                 vector = cosine_similarity(query_embedding_result.embedding, chunk.embedding_json)
                 vector = cosine_similarity(query_embedding_result.embedding, chunk.embedding_json)
@@ -798,9 +812,9 @@ class KnowledgeApplicationService:
                 else 0.0
                 else 0.0
             )
             )
             score = round(
             score = round(
-                keyword * self.settings.retrieval_keyword_weight
-                + vector * self.settings.retrieval_vector_weight
-                + rerank * self.settings.retrieval_rerank_weight,
+                keyword * keyword_weight
+                + vector * vector_weight
+                + rerank * rerank_weight,
                 6)
                 6)
             scored.append(
             scored.append(
                 (
                 (
@@ -816,9 +830,9 @@ class KnowledgeApplicationService:
                         "rerank_enabled": self.settings.retrieval_rerank_enabled,
                         "rerank_enabled": self.settings.retrieval_rerank_enabled,
                         "candidate_limit": candidate_limit,
                         "candidate_limit": candidate_limit,
                         "weights": {
                         "weights": {
-                            "keyword": self.settings.retrieval_keyword_weight,
-                            "vector": self.settings.retrieval_vector_weight,
-                            "rerank": self.settings.retrieval_rerank_weight,
+                            "keyword": keyword_weight,
+                            "vector": vector_weight,
+                            "rerank": rerank_weight,
                         },
                         },
                         "embedding_provider": query_embedding_result.provider,
                         "embedding_provider": query_embedding_result.provider,
                         "embedding_model": query_embedding_result.model,
                         "embedding_model": query_embedding_result.model,
@@ -841,10 +855,14 @@ class KnowledgeApplicationService:
         content_text: str,
         content_text: str,
         chunk_size: int | None,
         chunk_size: int | None,
         chunk_overlap: int | None) -> list[KnowledgeChunk]:
         chunk_overlap: int | None) -> list[KnowledgeChunk]:
-        chunk_payloads = build_chunk_payloads(
+        source_type = document.source_type or "text"
+        resolved_size = chunk_size or self.settings.default_chunk_size
+        resolved_overlap = chunk_overlap or self.settings.default_chunk_overlap
+        chunk_payloads = chunk_document(
             content_text=content_text,
             content_text=content_text,
-            chunk_size=chunk_size or self.settings.default_chunk_size,
-            chunk_overlap=chunk_overlap or self.settings.default_chunk_overlap)
+            source_type=source_type,
+            chunk_size=resolved_size,
+            chunk_overlap=resolved_overlap)
         for chunk_payload in chunk_payloads:
         for chunk_payload in chunk_payloads:
             content_text = self._read_chunk_content(chunk_payload)
             content_text = self._read_chunk_content(chunk_payload)
             embedding_result = self.embedding_service.embed_text(content_text)
             embedding_result = self.embedding_service.embed_text(content_text)

+ 5 - 3
services/knowledge-service/app/bootstrap/settings.py

@@ -6,9 +6,11 @@ class KnowledgeServiceSettings(ServiceSettings):
     service_port: int = 8012
     service_port: int = 8012
     default_chunk_size: int = 800
     default_chunk_size: int = 800
     default_chunk_overlap: int = 120
     default_chunk_overlap: int = 120
-    embedding_dimensions: int = 32
-    embedding_model: str = "local-hash-v1"
-    embedding_provider: str = "local"
+    embedding_dimensions: int = 1536
+    embedding_model: str = "text-embedding-3-small"
+    embedding_provider: str = "model_gateway"
+    model_gateway_service_url: str = "http://127.0.0.1:8005"
+    model_gateway_timeout_seconds: float = 30.0
     embedding_base_url: str | None = None
     embedding_base_url: str | None = None
     embedding_api_key: str | None = None
     embedding_api_key: str | None = None
     embedding_timeout_seconds: float = 30.0
     embedding_timeout_seconds: float = 30.0

+ 3 - 1
services/knowledge-service/app/db/models/knowledge_chunk.py

@@ -6,6 +6,8 @@ from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.sql.expression import ColumnElement
 from sqlalchemy.sql.expression import ColumnElement
 from sqlalchemy.types import UserDefinedType
 from sqlalchemy.types import UserDefinedType
 
 
+EMBEDDING_DIMENSIONS = 1536
+
 
 
 class PgVector(UserDefinedType[str]):
 class PgVector(UserDefinedType[str]):
     cache_ok = True
     cache_ok = True
@@ -30,5 +32,5 @@ class KnowledgeChunk(EntityMixin, AuditMixin, Base):
     token_count: Mapped[int] = mapped_column(Integer, default=0)
     token_count: Mapped[int] = mapped_column(Integer, default=0)
     embedding_model: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
     embedding_model: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
     embedding_json: Mapped[list[float] | None] = mapped_column(JSON, nullable=True)
     embedding_json: Mapped[list[float] | None] = mapped_column(JSON, nullable=True)
-    embedding_vector: Mapped[str | None] = mapped_column(PgVector(32), nullable=True)
+    embedding_vector: Mapped[str | None] = mapped_column(PgVector(EMBEDDING_DIMENSIONS), nullable=True)
     metadata_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
     metadata_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)

+ 12 - 0
services/model-gateway-service/app/api/routes.py

@@ -5,6 +5,8 @@ from typing import Annotated, TypeVar
 from core_domain import (
 from core_domain import (
     ChatCompletionRequestContract,
     ChatCompletionRequestContract,
     ChatCompletionResponseContract,
     ChatCompletionResponseContract,
+    EmbeddingRequestContract,
+    EmbeddingResponseContract,
     ServiceHealth,
     ServiceHealth,
 )
 )
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
@@ -196,6 +198,16 @@ def stream_chat_completion(
         })
         })
 
 
 
 
+@router.post("/embeddings", response_model=EmbeddingResponseContract)
+def create_embedding(
+    payload: EmbeddingRequestContract,
+    service: ModelServiceDep) -> EmbeddingResponseContract:
+    try:
+        return service.create_embedding(payload)
+    except ModelProviderClientError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
 @router.post("/list", response_model=ApiResponse[PageResult[ModelDto]])
 @router.post("/list", response_model=ApiResponse[PageResult[ModelDto]])
 def list_models_contract(
 def list_models_contract(
     payload: PageRequest,
     payload: PageRequest,

+ 33 - 1
services/model-gateway-service/app/application/services.py

@@ -1,4 +1,9 @@
-from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
+from core_domain import (
+    ChatCompletionRequestContract,
+    ChatCompletionResponseContract,
+    EmbeddingRequestContract,
+    EmbeddingResponseContract,
+)
 from collections.abc import Iterator
 from collections.abc import Iterator
 
 
 from app.bootstrap.settings import ModelGatewayServiceSettings
 from app.bootstrap.settings import ModelGatewayServiceSettings
@@ -210,6 +215,33 @@ class ModelGatewayApplicationService:
             resolved_payload,
             resolved_payload,
             provider_type=self.settings.provider_type)
             provider_type=self.settings.provider_type)
 
 
+    def create_embedding(
+        self,
+        payload: EmbeddingRequestContract) -> EmbeddingResponseContract:
+        configured_model = None
+        if payload.model:
+            configured_model = self.model_repository.get_active_for_request(payload.model)
+
+        if configured_model is not None:
+            configured_provider = self._resolve_model_provider(configured_model)
+            resolved_payload = payload.model_copy(
+                update={"model": configured_model.model_name}
+            )
+            return self.provider_client.create_embedding(
+                resolved_payload,
+                provider_type=configured_provider.provider_type,
+                provider_base_url=configured_provider.provider_base_url,
+                provider_api_key=configured_provider.provider_api_key,
+                timeout_seconds=configured_model.timeout_seconds,
+            )
+
+        resolved_payload = payload.model_copy(
+            update={"model": payload.model or self.settings.default_model}
+        )
+        return self.provider_client.create_embedding(
+            resolved_payload,
+            provider_type=self.settings.provider_type)
+
     def stream_chat_completion(
     def stream_chat_completion(
         self,
         self,
         payload: ChatCompletionRequestContract) -> Iterator[str]:
         payload: ChatCompletionRequestContract) -> Iterator[str]:

+ 83 - 1
services/model-gateway-service/app/infrastructure/provider.py

@@ -2,7 +2,13 @@ import json
 from collections.abc import Iterator
 from collections.abc import Iterator
 
 
 import httpx
 import httpx
-from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
+from core_domain import (
+    ChatCompletionRequestContract,
+    ChatCompletionResponseContract,
+    EmbeddingDataItem,
+    EmbeddingRequestContract,
+    EmbeddingResponseContract,
+)
 from core_shared import JSONValue
 from core_shared import JSONValue
 
 
 from app.bootstrap.settings import ModelGatewayServiceSettings
 from app.bootstrap.settings import ModelGatewayServiceSettings
@@ -69,6 +75,63 @@ class ModelProviderClient:
             provider_api_key=provider_api_key,
             provider_api_key=provider_api_key,
             timeout_seconds=timeout_seconds)
             timeout_seconds=timeout_seconds)
 
 
+    def create_embedding(
+        self,
+        payload: EmbeddingRequestContract,
+        *,
+        provider_type: str | None = None,
+        provider_base_url: str | None = None,
+        provider_api_key: str | None = None,
+        timeout_seconds: float = 60.0,
+    ) -> EmbeddingResponseContract:
+        resolved_provider_type = provider_type or self.settings.provider_type
+        return self._create_openai_compatible_embedding(
+            payload,
+            provider_base_url=provider_base_url,
+            provider_api_key=provider_api_key,
+            timeout_seconds=timeout_seconds)
+
+    def _create_openai_compatible_embedding(
+        self,
+        payload: EmbeddingRequestContract,
+        *,
+        provider_base_url: str | None,
+        provider_api_key: str | None,
+        timeout_seconds: float) -> EmbeddingResponseContract:
+        request_payload: dict[str, JSONValue] = {
+            "model": payload.model or "",
+            "input": payload.input,
+        }
+        if payload.dimensions is not None:
+            request_payload["dimensions"] = payload.dimensions
+
+        request_headers: dict[str, str] = {"content-type": "application/json"}
+        api_key = (
+            provider_api_key
+            if provider_api_key is not None
+            else self.settings.provider_api_key
+        )
+        if api_key:
+            request_headers["authorization"] = f"Bearer {api_key}"
+
+        try:
+            base_url = provider_base_url or self.settings.provider_base_url
+            with httpx.Client(timeout=timeout_seconds) as client:
+                response = client.post(
+                    _join_url(base_url, "embeddings"),
+                    json=request_payload,
+                    headers=request_headers)
+                response.raise_for_status()
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:1000]
+            raise ModelProviderClientError(
+                f"embedding request failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise ModelProviderClientError(f"embedding request failed: {exc}") from exc
+
+        response_json = _coerce_json_dict(response.json())
+        return _parse_embedding_response(response_json)
+
     def list_models(
     def list_models(
         self,
         self,
         *,
         *,
@@ -579,3 +642,22 @@ def _extract_usage_json(payload: dict[str, JSONValue]) -> dict[str, JSONValue]:
     if isinstance(usage, dict):
     if isinstance(usage, dict):
         return {str(key): value for key, value in usage.items()}
         return {str(key): value for key, value in usage.items()}
     return {}
     return {}
+
+
+def _parse_embedding_response(payload: dict[str, JSONValue]) -> EmbeddingResponseContract:
+    model = _read_string(payload, "model")
+    usage = payload.get("usage")
+    usage_json = {str(k): v for k, v in usage.items()} if isinstance(usage, dict) else {}
+    data_items: list[EmbeddingDataItem] = []
+    data = payload.get("data")
+    if isinstance(data, list):
+        for idx, item in enumerate(data):
+            if not isinstance(item, dict):
+                continue
+            embedding_raw = item.get("embedding")
+            if not isinstance(embedding_raw, list):
+                continue
+            embedding = [float(v) for v in embedding_raw if isinstance(v, (int, float)) and not isinstance(v, bool)]
+            index = item.get("index")
+            data_items.append(EmbeddingDataItem(embedding=embedding, index=index if isinstance(index, int) else idx))
+    return EmbeddingResponseContract(model=model, data=data_items, usage_json=usage_json)

+ 28 - 0
services/session-service/alembic/versions/20260512_0002_session_runtime_targets.py

@@ -0,0 +1,28 @@
+"""add session runtime target fields
+
+Revision ID: 20260512_0002_session
+Revises: 20260429_9001_session
+Create Date: 2026-05-12 00:00:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260512_0002_session"
+down_revision: str | None = "20260429_9001_session"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column("session", sa.Column("runtime_target_type", sa.String(length=32), nullable=True))
+    op.add_column("session", sa.Column("runtime_target_id", sa.String(length=36), nullable=True))
+    op.add_column("session", sa.Column("runtime_target_config_id", sa.String(length=36), nullable=True))
+
+
+def downgrade() -> None:
+    op.drop_column("session", "runtime_target_config_id")
+    op.drop_column("session", "runtime_target_id")
+    op.drop_column("session", "runtime_target_type")

+ 34 - 2
services/session-service/app/api/routes.py

@@ -1,5 +1,5 @@
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
@@ -10,10 +10,12 @@ from app.domain.repositories import MessageRepository, RunRequestRepository, Ses
 from app.schemas.message import MessageCreateRequest, MessageListRequest, MessageResponse
 from app.schemas.message import MessageCreateRequest, MessageListRequest, MessageResponse
 from app.schemas.run_request import (
 from app.schemas.run_request import (
     RunRequestCreateRequest,
     RunRequestCreateRequest,
+    RunRequestDetailRequest,
     RunRequestListRequest,
     RunRequestListRequest,
     RunRequestResponse,
     RunRequestResponse,
+    RunRequestUpdateRequest,
 )
 )
-from app.schemas.session import SessionCreateRequest, SessionListRequest, SessionResponse
+from app.schemas.session import SessionCreateRequest, SessionDetailRequest, SessionListRequest, SessionResponse
 
 
 router = APIRouter()
 router = APIRouter()
 
 
@@ -59,6 +61,16 @@ def list_sessions_post(
     return [SessionResponse.from_entity(item) for item in service.list_sessions(payload.app_id)]
     return [SessionResponse.from_entity(item) for item in service.list_sessions(payload.app_id)]
 
 
 
 
+@router.post("/detail", response_model=SessionResponse)
+def detail_session(
+    payload: SessionDetailRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> SessionResponse:
+    entity = service.get_session(session_id=payload.session_id)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"session not found: {payload.session_id}")
+    return SessionResponse.from_entity(entity)
+
+
 @router.post("/messages", response_model=MessageResponse)
 @router.post("/messages", response_model=MessageResponse)
 def create_message(
 def create_message(
     payload: MessageCreateRequest,
     payload: MessageCreateRequest,
@@ -113,3 +125,23 @@ def list_run_requests_post(
         RunRequestResponse.from_entity(item)
         RunRequestResponse.from_entity(item)
         for item in service.list_run_requests(session_id=payload.session_id)
         for item in service.list_run_requests(session_id=payload.session_id)
     ]
     ]
+
+
+@router.post("/run-requests/detail", response_model=RunRequestResponse)
+def get_run_request(
+    payload: RunRequestDetailRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
+    entity = service.run_request_repository.get_by_id(run_request_id=payload.run_request_id)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+    return RunRequestResponse.from_entity(entity)
+
+
+@router.post("/run-requests/update", response_model=RunRequestResponse)
+def update_run_request(
+    payload: RunRequestUpdateRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
+    entity = service.update_run_request(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+    return RunRequestResponse.from_entity(entity)

+ 14 - 2
services/session-service/app/application/services.py

@@ -2,7 +2,7 @@ from app.db.models import Message, RunRequest
 from app.db.models import Session as SessionModel
 from app.db.models import Session as SessionModel
 from app.domain.repositories import MessageRepository, RunRequestRepository, SessionRepository
 from app.domain.repositories import MessageRepository, RunRequestRepository, SessionRepository
 from app.schemas.message import MessageCreateRequest
 from app.schemas.message import MessageCreateRequest
-from app.schemas.run_request import RunRequestCreateRequest
+from app.schemas.run_request import RunRequestCreateRequest, RunRequestUpdateRequest
 from app.schemas.session import SessionCreateRequest
 from app.schemas.session import SessionCreateRequest
 
 
 
 
@@ -21,11 +21,17 @@ class SessionApplicationService:
             app_id=payload.app_id,
             app_id=payload.app_id,
             user_id=payload.user_id,
             user_id=payload.user_id,
             channel_type=payload.channel_type,
             channel_type=payload.channel_type,
-            title=payload.title)
+            title=payload.title,
+            runtime_target_type=payload.runtime_target_type,
+            runtime_target_id=payload.runtime_target_id,
+            runtime_target_config_id=payload.runtime_target_config_id)
 
 
     def list_sessions(self, app_id: str | None = None) -> list[SessionModel]:
     def list_sessions(self, app_id: str | None = None) -> list[SessionModel]:
         return self.session_repository.list_by_scope(app_id=app_id)
         return self.session_repository.list_by_scope(app_id=app_id)
 
 
+    def get_session(self, *, session_id: str) -> SessionModel | None:
+        return self.session_repository.get_by_id(session_id=session_id)
+
     def create_message(self, payload: MessageCreateRequest) -> Message:
     def create_message(self, payload: MessageCreateRequest) -> Message:
         return self.message_repository.create(
         return self.message_repository.create(
             session_id=payload.session_id,
             session_id=payload.session_id,
@@ -49,3 +55,9 @@ class SessionApplicationService:
 
 
     def list_run_requests(self, session_id: str) -> list[RunRequest]:
     def list_run_requests(self, session_id: str) -> list[RunRequest]:
         return self.run_request_repository.list_by_session(session_id=session_id)
         return self.run_request_repository.list_by_session(session_id=session_id)
+
+    def update_run_request(self, payload: RunRequestUpdateRequest) -> RunRequest | None:
+        return self.run_request_repository.update(
+            run_request_id=payload.run_request_id,
+            request_payload_json=payload.request_payload_json,
+            request_status=payload.request_status)

+ 3 - 0
services/session-service/app/db/models/session.py

@@ -13,6 +13,9 @@ class Session(EntityMixin, AuditMixin, Base):
     channel_type: Mapped[str] = mapped_column(String(32))
     channel_type: Mapped[str] = mapped_column(String(32))
     session_status: Mapped[str] = mapped_column(String(32), default="active")
     session_status: Mapped[str] = mapped_column(String(32), default="active")
     title: Mapped[str | None] = mapped_column(String(256), nullable=True)
     title: Mapped[str | None] = mapped_column(String(256), nullable=True)
+    runtime_target_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    runtime_target_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    runtime_target_config_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
     started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_active_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_active_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     closed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     closed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 39 - 1
services/session-service/app/domain/repositories.py

@@ -17,12 +17,18 @@ class SessionRepository:
         app_id: str,
         app_id: str,
         user_id: str,
         user_id: str,
         channel_type: str,
         channel_type: str,
-        title: str | None) -> SessionModel:
+        title: str | None,
+        runtime_target_type: str | None,
+        runtime_target_id: str | None,
+        runtime_target_config_id: str | None) -> SessionModel:
         entity = SessionModel(
         entity = SessionModel(
             app_id=app_id,
             app_id=app_id,
             user_id=user_id,
             user_id=user_id,
             channel_type=channel_type,
             channel_type=channel_type,
             title=title,
             title=title,
+            runtime_target_type=runtime_target_type,
+            runtime_target_id=runtime_target_id,
+            runtime_target_config_id=runtime_target_config_id,
             started_time=datetime.utcnow(),
             started_time=datetime.utcnow(),
             last_active_time=datetime.utcnow())
             last_active_time=datetime.utcnow())
         self.db.add(entity)
         self.db.add(entity)
@@ -30,6 +36,10 @@ class SessionRepository:
         self.db.refresh(entity)
         self.db.refresh(entity)
         return entity
         return entity
 
 
+    def get_by_id(self, *, session_id: str) -> SessionModel | None:
+        stmt = select(SessionModel).where(SessionModel.id == session_id)
+        return self.db.scalars(stmt).first()
+
     def list_by_scope(self, *, app_id: str | None = None) -> list[SessionModel]:
     def list_by_scope(self, *, app_id: str | None = None) -> list[SessionModel]:
         stmt = select(SessionModel)
         stmt = select(SessionModel)
         if app_id:
         if app_id:
@@ -57,6 +67,12 @@ class MessageRepository:
             content_type=content_type,
             content_type=content_type,
             content_text=content_text,
             content_text=content_text,
             content_json=content_json)
             content_json=content_json)
+        session = self.db.scalars(
+            select(SessionModel).where(SessionModel.id == session_id)
+        ).first()
+        if session is not None:
+            session.last_active_time = datetime.utcnow()
+            self.db.add(session)
         self.db.add(entity)
         self.db.add(entity)
         self.db.commit()
         self.db.commit()
         self.db.refresh(entity)
         self.db.refresh(entity)
@@ -103,3 +119,25 @@ class RunRequestRepository:
             .order_by(RunRequest.created_time.desc())
             .order_by(RunRequest.created_time.desc())
         )
         )
         return list(self.db.scalars(stmt))
         return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, run_request_id: str) -> RunRequest | None:
+        stmt = select(RunRequest).where(RunRequest.id == run_request_id)
+        return self.db.scalars(stmt).first()
+
+    def update(
+        self,
+        *,
+        run_request_id: str,
+        request_payload_json: dict | None = None,
+        request_status: str | None = None) -> RunRequest | None:
+        entity = self.get_by_id(run_request_id=run_request_id)
+        if entity is None:
+            return None
+        if request_payload_json is not None:
+            entity.request_payload_json = request_payload_json
+        if request_status is not None:
+            entity.request_status = request_status
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 10 - 0
services/session-service/app/schemas/run_request.py

@@ -21,6 +21,16 @@ class RunRequestListRequest(BaseModel):
     session_id: str
     session_id: str
 
 
 
 
+class RunRequestDetailRequest(BaseModel):
+    run_request_id: str
+
+
+class RunRequestUpdateRequest(BaseModel):
+    run_request_id: str
+    request_payload_json: dict[str, JSONValue] | None = None
+    request_status: str | None = None
+
+
 class RunRequestResponse(BaseModel):
 class RunRequestResponse(BaseModel):
     id: str
     id: str
     session_id: str
     session_id: str

+ 10 - 0
services/session-service/app/schemas/session.py

@@ -12,12 +12,19 @@ class SessionCreateRequest(BaseModel):
     user_id: str
     user_id: str
     channel_type: str = "web"
     channel_type: str = "web"
     title: str | None = None
     title: str | None = None
+    runtime_target_type: str | None = None
+    runtime_target_id: str | None = None
+    runtime_target_config_id: str | None = None
 
 
 
 
 class SessionListRequest(BaseModel):
 class SessionListRequest(BaseModel):
     app_id: str | None = None
     app_id: str | None = None
 
 
 
 
+class SessionDetailRequest(BaseModel):
+    session_id: str
+
+
 class SessionResponse(BaseModel):
 class SessionResponse(BaseModel):
     id: str
     id: str
     app_id: str
     app_id: str
@@ -25,6 +32,9 @@ class SessionResponse(BaseModel):
     channel_type: str
     channel_type: str
     session_status: str
     session_status: str
     title: str | None = None
     title: str | None = None
+    runtime_target_type: str | None = None
+    runtime_target_id: str | None = None
+    runtime_target_config_id: str | None = None
     started_time: datetime | None = None
     started_time: datetime | None = None
     last_active_time: datetime | None = None
     last_active_time: datetime | None = None
     created_time: datetime
     created_time: datetime

+ 313 - 87
services/team-service/app/application/services.py

@@ -139,9 +139,6 @@ class TeamApplicationService:
                 member_refs=self._normalize_member_refs(payload.memberRefs),
                 member_refs=self._normalize_member_refs(payload.memberRefs),
                 policy_json=payload.policy))
                 policy_json=payload.policy))
 
 
-    def list_team_configs(self, *, team_id: str) -> list[TeamConfig]:
-        return self.team_config_repository.list_by_team(team_id=team_id)
-
     def list_team_configs(self, *, team_id: str | None = None) -> list[TeamConfig]:
     def list_team_configs(self, *, team_id: str | None = None) -> list[TeamConfig]:
         if team_id is not None:
         if team_id is not None:
             return self.team_config_repository.list_by_team(team_id=team_id)
             return self.team_config_repository.list_by_team(team_id=team_id)
@@ -330,7 +327,8 @@ class TeamApplicationService:
                 self._member_result_to_json(item) for item in member_results
                 self._member_result_to_json(item) for item in member_results
             ],
             ],
         }
         }
-        if failed_results:
+        failure_mode = self._read_failure_mode(team_config)
+        if failed_results and failure_mode != "continue_with_warning":
             failed_run = self.team_run_repository.update_status(
             failed_run = self.team_run_repository.update_status(
                 team_run_id=team_run.id,
                 team_run_id=team_run.id,
                 status="failed",
                 status="failed",
@@ -416,65 +414,16 @@ class TeamApplicationService:
             return
             return
 
 
         stream_members = self._select_stream_members(team_config=team_config, members=members)
         stream_members = self._select_stream_members(team_config=team_config, members=members)
-        member_results: list[TeamMemberRunResult] = []
-        prior_outputs: list[dict[str, JSONValue]] = []
-        for member in stream_members:
-            member_input_json = self._build_member_input_json(
-                team_run=team_run,
-                team_config=team_config,
-                member=member,
-                prior_outputs=prior_outputs)
-            created_run = self.agent_client.create_agent_run(
-                agent_id=member.agent_id,
-                agent_config_id=member.agent_config_id,
-                session_id=team_run.session_id,
-                input_text=self._build_member_input_text(
-                    team_run=team_run,
-                    team_config=team_config,
-                    member=member),
-                input_json=member_input_json)
-            yield {
-                "event": "team.member.started",
-                "member": self._member_to_json(member),
-                "agent_run": created_run.model_dump(mode="json"),
-            }
+        mode = team_config.coordination_mode
 
 
-            final_agent_run = created_run
-            try:
-                for event_name, data in self.agent_client.execute_agent_run_stream(
-                    agent_run_id=created_run.id,
-                    worker_key=payload.worker_key,
-                    dry_run=payload.dry_run):
-                    if event_name == "agent.run.delta":
-                        delta = data.get("delta")
-                        if isinstance(delta, str):
-                            yield {
-                                "event": "team.member.delta",
-                                "member": self._member_to_json(member),
-                                "agent_run_id": created_run.id,
-                                "delta": delta,
-                            }
-                    elif event_name in {"agent.run.completed", "agent.run.failed"}:
-                        run_payload = data.get("run")
-                        if isinstance(run_payload, dict):
-                            final_agent_run = AgentRunContract.model_validate(run_payload)
-            except AgentServiceClientError as exc:
-                failed_agent_run = created_run.model_copy(
-                    update={
-                        "status": "failed",
-                        "error_code": "agent_service_error",
-                        "error_message": str(exc),
-                    })
-                final_agent_run = failed_agent_run
-
-            result = TeamMemberRunResult(member=member, run=final_agent_run)
-            member_results.append(result)
-            prior_outputs.append(self._compact_prior_output(result))
-            yield {
-                "event": "team.member.completed",
-                "member": self._member_to_json(member),
-                "agent_run": final_agent_run.model_dump(mode="json"),
-            }
+        if mode == "debate":
+            member_results = yield from self._stream_members_debate(
+                team_run=team_run, team_config=team_config, members=stream_members,
+                payload=payload)
+        else:
+            member_results = yield from self._stream_members_sequential(
+                team_run=team_run, team_config=team_config, members=stream_members,
+                payload=payload)
 
 
         failed_results = [item for item in member_results if item.run.status != "completed"]
         failed_results = [item for item in member_results if item.run.status != "completed"]
         output_text = self._build_team_output_text(
         output_text = self._build_team_output_text(
@@ -491,7 +440,8 @@ class TeamApplicationService:
             "streamed": True,
             "streamed": True,
             "response_mode": self._read_response_mode(team_config),
             "response_mode": self._read_response_mode(team_config),
         }
         }
-        if failed_results:
+        failure_mode = self._read_failure_mode(team_config)
+        if failed_results and failure_mode != "continue_with_warning":
             failed_run = self.team_run_repository.update_status(
             failed_run = self.team_run_repository.update_status(
                 team_run_id=team_run.id,
                 team_run_id=team_run.id,
                 status="failed",
                 status="failed",
@@ -587,34 +537,187 @@ class TeamApplicationService:
             raise AgentServiceClientError("agent service client is not configured")
             raise AgentServiceClientError("agent service client is not configured")
 
 
         ordered_members = self._order_members(members)
         ordered_members = self._order_members(members)
-        if self._should_execute_members_in_parallel(team_config):
-            return self._execute_members_in_parallel(
-                team_run=team_run,
-                team_config=team_config,
-                members=ordered_members,
-                worker_key=worker_key,
-                dry_run=dry_run)
+        handoff = team_config.policy_json.get("handoff")
+        mode = team_config.coordination_mode
+
+        if mode == "parallel" or handoff == "parallel_merge":
+            return self._execute_members_parallel(
+                team_run=team_run, team_config=team_config,
+                members=ordered_members, worker_key=worker_key, dry_run=dry_run)
+        if mode == "pipeline":
+            return self._execute_members_pipeline(
+                team_run=team_run, team_config=team_config,
+                members=ordered_members, worker_key=worker_key, dry_run=dry_run)
+        if mode == "debate":
+            return self._execute_members_debate(
+                team_run=team_run, team_config=team_config,
+                members=ordered_members, worker_key=worker_key, dry_run=dry_run)
+        return self._execute_members_supervisor(
+            team_run=team_run, team_config=team_config,
+            members=ordered_members, worker_key=worker_key, dry_run=dry_run)
+
+    def _execute_members_supervisor(
+        self,
+        *,
+        team_run: TeamRun,
+        team_config: TeamConfig,
+        members: list[TeamMemberContract],
+        worker_key: str | None,
+        dry_run: bool) -> list[TeamMemberRunResult]:
+        lead = next((m for m in members if m.role in {"supervisor", "planner"}), None)
+        others = [m for m in members if m is not lead] if lead else members
+        failure_mode = self._read_failure_mode(team_config)
+
+        if lead is None:
+            return self._execute_members_sequential(
+                team_run=team_run, team_config=team_config, members=members,
+                worker_key=worker_key, dry_run=dry_run, failure_mode=failure_mode)
+
+        # Phase 1: lead executes first
+        lead_input = self._build_member_input_json(
+            team_run=team_run, team_config=team_config, member=lead, prior_outputs=[])
+        lead_result = self._execute_single_member(
+            team_run=team_run, team_config=team_config, member=lead,
+            member_input_json=lead_input, worker_key=worker_key, dry_run=dry_run)
+
+        if lead_result.run.status != "completed" and failure_mode == "stop_on_critical":
+            return [lead_result]
+
+        # Phase 2: others execute with lead output as context
+        lead_output = self._compact_prior_output(lead_result)
+        other_results = self._execute_members_sequential(
+            team_run=team_run, team_config=team_config, members=others,
+            worker_key=worker_key, dry_run=dry_run, failure_mode=failure_mode,
+            initial_prior_outputs=[lead_output])
+
+        # Phase 3: optional synthesis pass
+        do_synthesis = team_config.policy_json.get("supervisor_synthesis", True)
+        if do_synthesis and lead_result.run.status == "completed":
+            all_outputs = [lead_output] + [self._compact_prior_output(r) for r in other_results]
+            synthesis_input = self._build_member_input_json(
+                team_run=team_run, team_config=team_config, member=lead,
+                prior_outputs=all_outputs)
+            synthesis_result = self._execute_single_member(
+                team_run=team_run, team_config=team_config, member=lead,
+                member_input_json=synthesis_input, worker_key=worker_key, dry_run=dry_run)
+            return [lead_result] + other_results + [synthesis_result]
+
+        return [lead_result] + other_results
+
+    def _execute_members_pipeline(
+        self,
+        *,
+        team_run: TeamRun,
+        team_config: TeamConfig,
+        members: list[TeamMemberContract],
+        worker_key: str | None,
+        dry_run: bool) -> list[TeamMemberRunResult]:
+        failure_mode = self._read_failure_mode(team_config)
+        member_results: list[TeamMemberRunResult] = []
+        prev_output: dict[str, JSONValue] | None = None
+
+        for member in members:
+            prior = [prev_output] if prev_output is not None else []
+            member_input_json = self._build_member_input_json(
+                team_run=team_run, team_config=team_config, member=member, prior_outputs=prior)
+            result = self._execute_single_member(
+                team_run=team_run, team_config=team_config, member=member,
+                member_input_json=member_input_json, worker_key=worker_key, dry_run=dry_run)
+            member_results.append(result)
+
+            if result.run.status == "completed":
+                prev_output = self._compact_prior_output(result)
+            elif failure_mode == "stop_on_critical":
+                break
+            elif failure_mode == "retry_once":
+                retry_result = self._execute_single_member(
+                    team_run=team_run, team_config=team_config, member=member,
+                    member_input_json=member_input_json, worker_key=worker_key, dry_run=dry_run)
+                member_results[-1] = retry_result
+                if retry_result.run.status == "completed":
+                    prev_output = self._compact_prior_output(retry_result)
+                elif failure_mode == "stop_on_critical":
+                    break
 
 
+        return member_results
+
+    def _execute_members_debate(
+        self,
+        *,
+        team_run: TeamRun,
+        team_config: TeamConfig,
+        members: list[TeamMemberContract],
+        worker_key: str | None,
+        dry_run: bool) -> list[TeamMemberRunResult]:
+        max_rounds = self._read_max_rounds(team_config)
+        failure_mode = self._read_failure_mode(team_config)
+        debate_history: list[dict[str, JSONValue]] = []
+        final_results: list[TeamMemberRunResult] = []
+
+        for round_num in range(1, max_rounds + 1):
+            round_results: list[TeamMemberRunResult] = []
+            for member in members:
+                member_input_json = self._build_member_input_json(
+                    team_run=team_run, team_config=team_config, member=member,
+                    prior_outputs=debate_history)
+                result = self._execute_single_member(
+                    team_run=team_run, team_config=team_config, member=member,
+                    member_input_json=member_input_json, worker_key=worker_key, dry_run=dry_run)
+                round_results.append(result)
+                debate_history.append(self._compact_prior_output(result))
+
+                if result.run.status != "completed" and failure_mode == "stop_on_critical":
+                    break
+                if result.run.status != "completed" and failure_mode == "retry_once":
+                    retry = self._execute_single_member(
+                        team_run=team_run, team_config=team_config, member=member,
+                        member_input_json=member_input_json, worker_key=worker_key, dry_run=dry_run)
+                    round_results[-1] = retry
+                    debate_history[-1] = self._compact_prior_output(retry)
+
+            final_results = round_results
+            if any(r.run.status != "completed" for r in round_results) and failure_mode == "stop_on_critical":
+                break
+
+        return final_results
+
+    def _execute_members_sequential(
+        self,
+        *,
+        team_run: TeamRun,
+        team_config: TeamConfig,
+        members: list[TeamMemberContract],
+        worker_key: str | None,
+        dry_run: bool,
+        failure_mode: str = "stop_on_critical",
+        initial_prior_outputs: list[dict[str, JSONValue]] | None = None) -> list[TeamMemberRunResult]:
         member_results: list[TeamMemberRunResult] = []
         member_results: list[TeamMemberRunResult] = []
-        prior_outputs: list[dict[str, JSONValue]] = []
-        for member in ordered_members:
+        prior_outputs = list(initial_prior_outputs or [])
+
+        for member in members:
             member_input_json = self._build_member_input_json(
             member_input_json = self._build_member_input_json(
-                team_run=team_run,
-                team_config=team_config,
-                member=member,
-                prior_outputs=prior_outputs)
+                team_run=team_run, team_config=team_config, member=member, prior_outputs=prior_outputs)
             result = self._execute_single_member(
             result = self._execute_single_member(
-                team_run=team_run,
-                team_config=team_config,
-                member=member,
-                member_input_json=member_input_json,
-                worker_key=worker_key,
-                dry_run=dry_run)
+                team_run=team_run, team_config=team_config, member=member,
+                member_input_json=member_input_json, worker_key=worker_key, dry_run=dry_run)
             member_results.append(result)
             member_results.append(result)
             prior_outputs.append(self._compact_prior_output(result))
             prior_outputs.append(self._compact_prior_output(result))
+
+            if result.run.status != "completed":
+                if failure_mode == "stop_on_critical":
+                    break
+                if failure_mode == "retry_once":
+                    retry = self._execute_single_member(
+                        team_run=team_run, team_config=team_config, member=member,
+                        member_input_json=member_input_json, worker_key=worker_key, dry_run=dry_run)
+                    member_results[-1] = retry
+                    prior_outputs[-1] = self._compact_prior_output(retry)
+                    if retry.run.status != "completed" and failure_mode == "stop_on_critical":
+                        break
+
         return member_results
         return member_results
 
 
-    def _execute_members_in_parallel(
+    def _execute_members_parallel(
         self,
         self,
         *,
         *,
         team_run: TeamRun,
         team_run: TeamRun,
@@ -670,9 +773,120 @@ class TeamApplicationService:
             dry_run=dry_run)
             dry_run=dry_run)
         return TeamMemberRunResult(member=member, run=executed_run)
         return TeamMemberRunResult(member=member, run=executed_run)
 
 
-    def _should_execute_members_in_parallel(self, team_config: TeamConfig) -> bool:
-        handoff = team_config.policy_json.get("handoff")
-        return team_config.coordination_mode == "parallel" or handoff == "parallel_merge"
+    def _stream_single_member(
+        self,
+        *,
+        team_run: TeamRun,
+        team_config: TeamConfig,
+        member: TeamMemberContract,
+        prior_outputs: list[dict[str, JSONValue]],
+        payload: TeamRunExecuteRequest) -> Iterator[tuple[dict[str, JSONValue], TeamMemberRunResult]]:
+        member_input_json = self._build_member_input_json(
+            team_run=team_run, team_config=team_config, member=member, prior_outputs=prior_outputs)
+        created_run = self.agent_client.create_agent_run(
+            agent_id=member.agent_id,
+            agent_config_id=member.agent_config_id,
+            session_id=team_run.session_id,
+            input_text=self._build_member_input_text(
+                team_run=team_run, team_config=team_config, member=member),
+            input_json=member_input_json)
+        yield {
+            "event": "team.member.started",
+            "member": self._member_to_json(member),
+            "agent_run": created_run.model_dump(mode="json"),
+        }, None
+
+        final_agent_run = created_run
+        try:
+            for event_name, data in self.agent_client.execute_agent_run_stream(
+                agent_run_id=created_run.id,
+                worker_key=payload.worker_key,
+                dry_run=payload.dry_run):
+                if event_name == "agent.run.delta":
+                    delta = data.get("delta")
+                    if isinstance(delta, str):
+                        yield {
+                            "event": "team.member.delta",
+                            "member": self._member_to_json(member),
+                            "agent_run_id": created_run.id,
+                            "delta": delta,
+                        }, None
+                elif event_name in {"agent.run.completed", "agent.run.failed"}:
+                    run_payload = data.get("run")
+                    if isinstance(run_payload, dict):
+                        final_agent_run = AgentRunContract.model_validate(run_payload)
+        except AgentServiceClientError as exc:
+            final_agent_run = created_run.model_copy(update={
+                "status": "failed",
+                "error_code": "agent_service_error",
+                "error_message": str(exc),
+            })
+
+        result = TeamMemberRunResult(member=member, run=final_agent_run)
+        yield {
+            "event": "team.member.completed",
+            "member": self._member_to_json(member),
+            "agent_run": final_agent_run.model_dump(mode="json"),
+        }, result
+
+    def _stream_members_sequential(
+        self,
+        *,
+        team_run: TeamRun,
+        team_config: TeamConfig,
+        members: list[TeamMemberContract],
+        payload: TeamRunExecuteRequest) -> Iterator[list[TeamMemberRunResult]]:
+        member_results: list[TeamMemberRunResult] = []
+        prior_outputs: list[dict[str, JSONValue]] = []
+
+        for member in members:
+            for event, result in self._stream_single_member(
+                team_run=team_run, team_config=team_config, member=member,
+                prior_outputs=prior_outputs, payload=payload):
+                if result is not None:
+                    member_results.append(result)
+                    prior_outputs.append(self._compact_prior_output(result))
+                else:
+                    yield event
+
+        return member_results
+
+    def _stream_members_debate(
+        self,
+        *,
+        team_run: TeamRun,
+        team_config: TeamConfig,
+        members: list[TeamMemberContract],
+        payload: TeamRunExecuteRequest) -> Iterator[list[TeamMemberRunResult]]:
+        max_rounds = self._read_max_rounds(team_config)
+        debate_history: list[dict[str, JSONValue]] = []
+        final_results: list[TeamMemberRunResult] = []
+
+        for round_num in range(1, max_rounds + 1):
+            yield {
+                "event": "team.debate.round_started",
+                "round": round_num,
+                "max_rounds": max_rounds,
+            }
+            round_results: list[TeamMemberRunResult] = []
+            for member in members:
+                for event, result in self._stream_single_member(
+                    team_run=team_run, team_config=team_config, member=member,
+                    prior_outputs=debate_history, payload=payload):
+                    if result is not None:
+                        round_results.append(result)
+                        debate_history.append(self._compact_prior_output(result))
+                    else:
+                        yield event
+            final_results = round_results
+            yield {
+                "event": "team.debate.round_completed",
+                "round": round_num,
+                "max_rounds": max_rounds,
+                "member_count": len(round_results),
+            }
+
+        return final_results
 
 
     def _read_team_members(self, team_config: TeamConfig) -> list[TeamMemberContract]:
     def _read_team_members(self, team_config: TeamConfig) -> list[TeamMemberContract]:
         members: list[TeamMemberContract] = []
         members: list[TeamMemberContract] = []
@@ -712,6 +926,18 @@ class TeamApplicationService:
             return value
             return value
         return "single_responder"
         return "single_responder"
 
 
+    def _read_max_rounds(self, team_config: TeamConfig) -> int:
+        value = team_config.policy_json.get("max_rounds")
+        if isinstance(value, (int, float)):
+            return max(1, min(int(value), 20))
+        return 3
+
+    def _read_failure_mode(self, team_config: TeamConfig) -> str:
+        value = team_config.policy_json.get("failure_mode")
+        if isinstance(value, str) and value in {"stop_on_critical", "continue_with_warning", "retry_once"}:
+            return value
+        return "stop_on_critical"
+
     def _build_member_input_text(
     def _build_member_input_text(
         self,
         self,
         *,
         *,
@@ -894,7 +1120,7 @@ class TeamApplicationService:
         members: list[TeamMemberContract] = []
         members: list[TeamMemberContract] = []
         for index, item in enumerate(member_refs, start=1):
         for index, item in enumerate(member_refs, start=1):
             role = item.get("role")
             role = item.get("role")
-            normalized_role = "executor" if role == "worker" else role
+            normalized_role = "specialist" if role == "worker" else role
             member = {
             member = {
                 **item,
                 **item,
                 "member_key": item.get("member_key") or item.get("memberKey") or f"member_{index}",
                 "member_key": item.get("member_key") or item.get("memberKey") or f"member_{index}",

+ 6 - 1
services/team-service/app/infrastructure/agent_client.py

@@ -118,7 +118,12 @@ class AgentServiceClient:
             payload["worker_key"] = worker_key
             payload["worker_key"] = worker_key
 
 
         try:
         try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
+            timeout = httpx.Timeout(
+                connect=self.timeout_seconds,
+                read=self.timeout_seconds,
+                write=self.timeout_seconds,
+                pool=self.timeout_seconds)
+            with httpx.Client(timeout=timeout) as client:
                 with client.stream(
                 with client.stream(
                     "POST",
                     "POST",
                     f"{self.base_url}/agents/runs/{agent_run_id}/execute-stream",
                     f"{self.base_url}/agents/runs/{agent_run_id}/execute-stream",

+ 205 - 1
tests/test_team_service.py

@@ -67,7 +67,7 @@ def test_team_service_post_contract_supports_team_configs_and_runs(
     assert config_response.status_code == 200
     assert config_response.status_code == 200
     config_payload = config_response.json()["data"]
     config_payload = config_response.json()["data"]
     assert config_payload["teamId"] == team_payload["id"]
     assert config_payload["teamId"] == team_payload["id"]
-    assert config_payload["memberRefs"][0]["role"] == "executor"
+    assert config_payload["memberRefs"][0]["role"] == "specialist"
     assert config_payload["memberRefs"][0]["member_key"] == "member_1"
     assert config_payload["memberRefs"][0]["member_key"] == "member_1"
 
 
     list_response = client.post(
     list_response = client.post(
@@ -192,3 +192,207 @@ def test_team_service_compacts_member_context_between_agent_calls() -> None:
     }
     }
     assert "messages" not in member_json["output_json"]
     assert "messages" not in member_json["output_json"]
     assert "raw_response_json" not in member_json["output_json"]
     assert "raw_response_json" not in member_json["output_json"]
+
+
+def _build_service_with_mock_agent() -> tuple:
+    prepare_known_service_import("team-service")
+    from unittest.mock import MagicMock
+    from app.application.services import TeamApplicationService, TeamMemberRunResult
+    from core_domain import AgentRunContract, TeamMemberContract
+
+    call_log: list[str] = []
+
+    def make_member_result(member: TeamMemberContract, text: str) -> TeamMemberRunResult:
+        return TeamMemberRunResult(
+            member=member,
+            run=AgentRunContract(
+                id=f"run_{member.member_key}",
+                agent_id=member.agent_id,
+                agent_config_id=member.agent_config_id,
+                output_text=text,
+                output_json={},
+                status="completed",
+                created_time=datetime.utcnow()))
+
+    mock_client = MagicMock()
+    mock_client.create_agent_run = MagicMock(
+        side_effect=lambda **kw: AgentRunContract(
+            id="run_mock", agent_id=kw.get("agent_id", "a"),
+            status="created", created_time=datetime.utcnow()))
+    mock_client.execute_agent_run = MagicMock(
+        side_effect=lambda **kw: AgentRunContract(
+            id=kw.get("agent_run_id", "run_mock"),
+            agent_id="a", output_text="ok",
+            output_json={}, status="completed",
+            created_time=datetime.utcnow()))
+
+    def track_execute(team_run, team_config, member, member_input_json, worker_key, dry_run):
+        prior = member_input_json.get("prior_member_outputs", [])
+        call_log.append(f"{member.member_key}:{member.role}:prior={len(prior)}")
+        return make_member_result(member, f"output_{member.member_key}")
+
+    service = TeamApplicationService(
+        team_repository=None,
+        team_config_repository=None,
+        team_run_repository=None,
+        agent_client=mock_client)
+    return service, call_log, track_execute
+
+
+def test_supervisor_mode_executes_lead_first_then_others() -> None:
+    service, call_log, track_execute = _build_service_with_mock_agent()
+    from unittest.mock import patch
+    from core_domain import TeamMemberContract
+
+    members = [
+        TeamMemberContract(member_key="worker_1", agent_id="a1", role="executor"),
+        TeamMemberContract(member_key="lead_1", agent_id="a2", role="supervisor"),
+        TeamMemberContract(member_key="worker_2", agent_id="a3", role="reviewer"),
+    ]
+
+    team_config = type("C", (), {
+        "coordination_mode": "supervisor",
+        "objective": "test",
+        "policy_json": {"supervisor_synthesis": True},
+    })()
+
+    with patch.object(service, "_execute_single_member", side_effect=track_execute):
+        results = service._execute_members(
+            team_run=MagicMock(), team_config=team_config,
+            members=members, worker_key=None, dry_run=False)
+
+    # lead runs first, then workers, then synthesis = 4 executions
+    assert len(results) == 4
+    keys = [r.member.member_key for r in results]
+    assert keys[0] == "lead_1"  # supervisor first
+    assert "worker_1" in keys[1:3]
+    assert "worker_2" in keys[1:3]
+    assert keys[3] == "lead_1"  # synthesis pass
+
+
+def test_pipeline_mode_chains_single_prior_output() -> None:
+    service, call_log, track_execute = _build_service_with_mock_agent()
+    from unittest.mock import patch
+    from core_domain import TeamMemberContract
+
+    members = [
+        TeamMemberContract(member_key="m1", agent_id="a1", role="planner"),
+        TeamMemberContract(member_key="m2", agent_id="a2", role="executor"),
+        TeamMemberContract(member_key="m3", agent_id="a3", role="reviewer"),
+    ]
+
+    team_config = type("C", (), {
+        "coordination_mode": "pipeline",
+        "objective": "test",
+        "policy_json": {},
+    })()
+
+    with patch.object(service, "_execute_single_member", side_effect=track_execute):
+        results = service._execute_members(
+            team_run=MagicMock(), team_config=team_config,
+            members=members, worker_key=None, dry_run=False)
+
+    assert len(results) == 3
+    # m1: no prior, m2: 1 prior, m3: 1 prior (only previous, not all)
+    assert call_log[0] == "m1:planner:prior=0"
+    assert call_log[1] == "m2:executor:prior=1"
+    assert call_log[2] == "m3:reviewer:prior=1"
+
+
+def test_debate_mode_executes_multiple_rounds() -> None:
+    service, call_log, track_execute = _build_service_with_mock_agent()
+    from unittest.mock import patch
+    from core_domain import TeamMemberContract
+
+    members = [
+        TeamMemberContract(member_key="m1", agent_id="a1", role="executor"),
+        TeamMemberContract(member_key="m2", agent_id="a2", role="reviewer"),
+    ]
+
+    team_config = type("C", (), {
+        "coordination_mode": "debate",
+        "objective": "test",
+        "policy_json": {"max_rounds": 3},
+    })()
+
+    with patch.object(service, "_execute_single_member", side_effect=track_execute):
+        results = service._execute_members(
+            team_run=MagicMock(), team_config=team_config,
+            members=members, worker_key=None, dry_run=False)
+
+    # 2 members x 3 rounds = 6 executions, final_results = last round
+    assert len(results) == 2
+    assert len(call_log) == 6
+    # Round 1: prior=0 for first, prior=1 for second
+    assert call_log[0] == "m1:executor:prior=0"
+    assert call_log[1] == "m2:reviewer:prior=1"
+    # Round 2: prior=2 (history from round 1)
+    assert call_log[2] == "m1:executor:prior=2"
+    assert call_log[3] == "m2:reviewer:prior=3"
+    # Round 3: prior=4
+    assert call_log[4] == "m1:executor:prior=4"
+
+
+def test_failure_mode_continue_allows_partial_failure() -> None:
+    service, call_log, track_execute = _build_service_with_mock_agent()
+    from unittest.mock import patch, MagicMock
+    from core_domain import TeamMemberContract, AgentRunContract
+
+    members = [
+        TeamMemberContract(member_key="m1", agent_id="a1", role="executor"),
+        TeamMemberContract(member_key="m2", agent_id="a2", role="executor"),
+    ]
+
+    team_config = type("C", (), {
+        "coordination_mode": "supervisor",
+        "objective": "test",
+        "policy_json": {"failure_mode": "continue_with_warning"},
+    })()
+
+    call_count = 0
+
+    def track_with_failure(team_run, team_config, member, member_input_json, worker_key, dry_run):
+        nonlocal call_count
+        call_count += 1
+        if member.member_key == "m1":
+            return TeamMemberRunResult(
+                member=member,
+                run=AgentRunContract(
+                    id="run_fail", agent_id="a1",
+                    status="failed", error_code="test_error",
+                    error_message="boom",
+                    created_time=datetime.utcnow()))
+        return make_member_result(member, f"output_{member.member_key}")
+
+    def make_member_result(member, text):
+        return TeamMemberRunResult(
+            member=member,
+            run=AgentRunContract(
+                id=f"run_{member.member_key}", agent_id=member.agent_id,
+                output_text=text, output_json={},
+                status="completed", created_time=datetime.utcnow()))
+
+    with patch.object(service, "_execute_single_member", side_effect=track_with_failure):
+        results = service._execute_members(
+            team_run=MagicMock(), team_config=team_config,
+            members=members, worker_key=None, dry_run=False)
+
+    # Both members executed despite first failing
+    assert call_count == 2
+    assert len(results) == 2
+
+
+def test_read_max_rounds_and_failure_mode_helpers() -> None:
+    service, _, _ = _build_service_with_mock_agent()
+
+    config_default = type("C", (), {"policy_json": {}})()
+    assert service._read_max_rounds(config_default) == 3
+    assert service._read_failure_mode(config_default) == "stop_on_critical"
+
+    config_custom = type("C", (), {"policy_json": {
+        "max_rounds": 5, "failure_mode": "continue_with_warning"}})()
+    assert service._read_max_rounds(config_custom) == 5
+    assert service._read_failure_mode(config_custom) == "continue_with_warning"
+
+    config_clamped = type("C", (), {"policy_json": {"max_rounds": 50}})()
+    assert service._read_max_rounds(config_clamped) == 20

+ 1 - 1
web/index.html

@@ -15,7 +15,7 @@
     </script>
     </script>
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Auto Platform</title>
+    <title>AgentDock</title>
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link
     <link

+ 3 - 0
web/src/App.tsx

@@ -19,6 +19,7 @@ const ModelsPage = lazy(() => import("@/pages/models/ModelsPage").then((module)
 const KnowledgePage = lazy(() => import("@/pages/knowledge/KnowledgePage").then((module) => ({ default: module.KnowledgePage })));
 const KnowledgePage = lazy(() => import("@/pages/knowledge/KnowledgePage").then((module) => ({ default: module.KnowledgePage })));
 const TeamsPage = lazy(() => import("@/pages/teams/TeamsPage").then((module) => ({ default: module.TeamsPage })));
 const TeamsPage = lazy(() => import("@/pages/teams/TeamsPage").then((module) => ({ default: module.TeamsPage })));
 const SkillsPage = lazy(() => import("@/pages/skills/SkillsPage").then((module) => ({ default: module.SkillsPage })));
 const SkillsPage = lazy(() => import("@/pages/skills/SkillsPage").then((module) => ({ default: module.SkillsPage })));
+const AppsPage = lazy(() => import("@/pages/apps/AppsPage").then((module) => ({ default: module.AppsPage })));
 const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage").then((module) => ({ default: module.SettingsPage })));
 const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage").then((module) => ({ default: module.SettingsPage })));
 
 
 export default function App() {
 export default function App() {
@@ -51,6 +52,7 @@ export default function App() {
               <Route path="/knowledge/:section" element={<KnowledgePage />} />
               <Route path="/knowledge/:section" element={<KnowledgePage />} />
               <Route path="/teams" element={<TeamsPage />} />
               <Route path="/teams" element={<TeamsPage />} />
               <Route path="/skills" element={<SkillsPage />} />
               <Route path="/skills" element={<SkillsPage />} />
+              <Route path="/apps" element={<AppsPage />} />
               <Route path="/settings" element={<SettingsPage />} />
               <Route path="/settings" element={<SettingsPage />} />
             </Route>
             </Route>
             <Route path="*" element={<Navigate to={defaultRoute || "/dashboard"} replace />} />
             <Route path="*" element={<Navigate to={defaultRoute || "/dashboard"} replace />} />
@@ -83,6 +85,7 @@ function RoutePreloader() {
         import("@/pages/knowledge/KnowledgePage"),
         import("@/pages/knowledge/KnowledgePage"),
         import("@/pages/teams/TeamsPage"),
         import("@/pages/teams/TeamsPage"),
         import("@/pages/skills/SkillsPage"),
         import("@/pages/skills/SkillsPage"),
+        import("@/pages/apps/AppsPage"),
         import("@/pages/settings/SettingsPage"),
         import("@/pages/settings/SettingsPage"),
       ]);
       ]);
     };
     };

+ 67 - 0
web/src/api/apps.ts

@@ -0,0 +1,67 @@
+import { apiClient } from "./client";
+import type {
+  AppApiKeyCreateResponse,
+  AppApiKeyResponse,
+  AppCreateRequest,
+  AppDefinition,
+  AppInvocationAuditResponse,
+  AppStatus,
+  AppUpdateRequest,
+} from "@/types";
+
+export async function listApps() {
+  const { data } = await apiClient.post<AppDefinition[]>("/apps/list", {});
+  return data;
+}
+
+export async function getApp(appId: string) {
+  const { data } = await apiClient.post<AppDefinition>("/apps/detail", { app_id: appId });
+  return data;
+}
+
+export async function createApp(payload: AppCreateRequest) {
+  const { data } = await apiClient.post<AppDefinition>("/apps", payload);
+  return data;
+}
+
+export async function updateApp(appId: string, payload: Omit<AppUpdateRequest, "app_id">) {
+  const { data } = await apiClient.post<AppDefinition>("/apps/update", {
+    app_id: appId,
+    ...payload,
+  });
+  return data;
+}
+
+export async function updateAppStatus(appId: string, status: AppStatus) {
+  const { data } = await apiClient.post<AppDefinition>("/apps/status", {
+    app_id: appId,
+    status,
+  });
+  return data;
+}
+
+export async function listAppApiKeys(appId: string) {
+  const { data } = await apiClient.post<AppApiKeyResponse[]>(`/apps/${appId}/api-keys/list`, {});
+  return data;
+}
+
+export async function createAppApiKey(
+  appId: string,
+  payload: { name: string; scopes?: string | null; expires_time?: string | null },
+) {
+  const { data } = await apiClient.post<AppApiKeyCreateResponse>(`/apps/${appId}/api-keys`, payload);
+  return data;
+}
+
+export async function updateAppApiKeyStatus(appId: string, apiKeyId: string, status: string) {
+  const { data } = await apiClient.post<AppApiKeyResponse>(`/apps/${appId}/api-keys/status`, {
+    api_key_id: apiKeyId,
+    status,
+  });
+  return data;
+}
+
+export async function listAppAudits(appId: string, limit = 100) {
+  const { data } = await apiClient.post<AppInvocationAuditResponse[]>(`/apps/${appId}/audits`, { limit });
+  return data;
+}

+ 1 - 0
web/src/api/index.ts

@@ -3,6 +3,7 @@ export * from "./health";
 export * from "./auth";
 export * from "./auth";
 export * from "./api-keys";
 export * from "./api-keys";
 export * from "./agents";
 export * from "./agents";
+export * from "./apps";
 export * from "./sessions";
 export * from "./sessions";
 export * from "./tools";
 export * from "./tools";
 export * from "./skills";
 export * from "./skills";

+ 2 - 2
web/src/api/mock.ts

@@ -6,7 +6,7 @@ import type {
   ApiResponse,
   ApiResponse,
   ApiKeyCreateResponse,
   ApiKeyCreateResponse,
   ApiKeyResponse,
   ApiKeyResponse,
-  AppResponse,
+  SessionAppResponse,
   AuthMeData,
   AuthMeData,
   DiscoverModelsResponse,
   DiscoverModelsResponse,
   DownstreamServiceHealth,
   DownstreamServiceHealth,
@@ -100,7 +100,7 @@ function toLegacyModelItems(models: Record<string, unknown>[]) {
   }));
   }));
 }
 }
 
 
-const apps: AppResponse[] = [
+const apps: SessionAppResponse[] = [
   {
   {
     id: "app_customer_ops",
     id: "app_customer_ops",
     name: "Customer Ops",
     name: "Customer Ops",

+ 87 - 2
web/src/api/sessions.ts

@@ -1,5 +1,7 @@
-import { apiClient } from "./client";
-import type { Message, RunRequest, Session } from "@/types";
+import { apiClient, normalizeGatewayBasePath } from "./client";
+import { useAuthStore } from "@/stores/auth";
+import { useUiStore } from "@/stores/ui";
+import type { Message, RunRequest, Session, SessionExecuteResult } from "@/types";
 
 
 export async function listSessions() {
 export async function listSessions() {
   const { data } = await apiClient.post<Session[]>("/sessions/list", {});
   const { data } = await apiClient.post<Session[]>("/sessions/list", {});
@@ -11,6 +13,9 @@ export async function createSession(payload: {
   user_id: string;
   user_id: string;
   channel_type: string;
   channel_type: string;
   title?: string | null;
   title?: string | null;
+  runtime_target_type?: string | null;
+  runtime_target_id?: string | null;
+  runtime_target_config_id?: string | null;
 }) {
 }) {
   const { data } = await apiClient.post<Session>("/sessions", payload);
   const { data } = await apiClient.post<Session>("/sessions", payload);
   return data;
   return data;
@@ -37,3 +42,83 @@ export async function listRunRequests(sessionId?: string) {
   const { data } = await apiClient.post<RunRequest[]>("/sessions/run-requests/list", { session_id: sessionId });
   const { data } = await apiClient.post<RunRequest[]>("/sessions/run-requests/list", { session_id: sessionId });
   return data;
   return data;
 }
 }
+
+export async function executeSession(payload: {
+  session_id: string;
+  message_text: string;
+  stream?: boolean;
+}) {
+  const { data } = await apiClient.post<SessionExecuteResult>("/sessions/execute", {
+    session_id: payload.session_id,
+    message_text: payload.message_text,
+    stream: payload.stream ?? false,
+  });
+  return data;
+}
+
+export function executeSessionStream(
+  payload: { session_id: string; message_text: string },
+  onEvent: (event: string, data: Record<string, unknown>) => void,
+): AbortController {
+  const controller = new AbortController();
+  const { accessToken, tokenType, userId } = useAuthStore.getState();
+  const baseUrl = normalizeGatewayBasePath(useUiStore.getState().siteSettings.gatewayBasePath);
+
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+    Accept: "text/event-stream",
+  };
+  if (accessToken) headers["Authorization"] = `${tokenType || "bearer"} ${accessToken}`;
+  if (userId) headers["x-user-id"] = userId;
+
+  fetch(`${baseUrl}/sessions/execute`, {
+    method: "POST",
+    headers,
+    body: JSON.stringify({ session_id: payload.session_id, message_text: payload.message_text, stream: true }),
+    signal: controller.signal,
+  }).then(async (response) => {
+    if (!response.ok) {
+      const text = await response.text();
+      try {
+        onEvent("session.execute.failed", { error_message: JSON.parse(text).detail || text });
+      } catch {
+        onEvent("session.execute.failed", { error_message: text || `HTTP ${response.status}` });
+      }
+      return;
+    }
+    const reader = response.body?.getReader();
+    if (!reader) {
+      onEvent("session.execute.failed", { error_message: "Streaming not supported" });
+      return;
+    }
+    const decoder = new TextDecoder();
+    let buffer = "";
+    let currentEvent = "message";
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split("\n");
+      buffer = lines.pop() || "";
+      for (const line of lines) {
+        if (line.startsWith("event:")) {
+          currentEvent = line.slice(6).trim();
+        } else if (line.startsWith("data:")) {
+          const dataStr = line.slice(5).trim();
+          try {
+            onEvent(currentEvent, JSON.parse(dataStr));
+          } catch {
+            onEvent(currentEvent, { raw: dataStr });
+          }
+          currentEvent = "message";
+        }
+      }
+    }
+  }).catch((err) => {
+    if (err.name !== "AbortError") {
+      onEvent("session.execute.failed", { error_message: err.message });
+    }
+  });
+
+  return controller;
+}

+ 9 - 2
web/src/api/teams.ts

@@ -264,6 +264,8 @@ export type TeamRunStreamEvent =
   | { type: "team.member.started"; member?: JSONObject; agent_run?: JSONObject }
   | { type: "team.member.started"; member?: JSONObject; agent_run?: JSONObject }
   | { type: "team.member.delta"; member?: JSONObject; agent_run_id?: string; delta: string }
   | { type: "team.member.delta"; member?: JSONObject; agent_run_id?: string; delta: string }
   | { type: "team.member.completed"; member?: JSONObject; agent_run?: JSONObject }
   | { type: "team.member.completed"; member?: JSONObject; agent_run?: JSONObject }
+  | { type: "team.debate.round_started"; round: number; max_rounds: number }
+  | { type: "team.debate.round_completed"; round: number; max_rounds: number; member_count: number }
   | { type: "team.run.completed"; run?: TeamRun }
   | { type: "team.run.completed"; run?: TeamRun }
   | { type: "team.run.failed"; run?: TeamRun }
   | { type: "team.run.failed"; run?: TeamRun }
   | { type: string; [key: string]: unknown };
   | { type: string; [key: string]: unknown };
@@ -330,7 +332,12 @@ function parseSseEvent(raw: string): TeamRunStreamEvent | undefined {
     if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trim());
     if (line.startsWith("data:")) dataLines.push(line.slice("data:".length).trim());
   });
   });
   if (!dataLines.length) return undefined;
   if (!dataLines.length) return undefined;
-  const payload = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
+  let payload: Record<string, unknown>;
+  try {
+    payload = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
+  } catch {
+    return undefined;
+  }
   return { type, ...normalizeStreamPayload(payload) } as TeamRunStreamEvent;
   return { type, ...normalizeStreamPayload(payload) } as TeamRunStreamEvent;
 }
 }
 
 
@@ -348,7 +355,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
 
 
 function normalizeMemberRef(member: JSONObject): JSONObject {
 function normalizeMemberRef(member: JSONObject): JSONObject {
   const rawRole = member.role;
   const rawRole = member.role;
-  const role = rawRole === "worker" ? "executor" : rawRole;
+  const role = rawRole === "worker" ? "specialist" : rawRole;
   if (typeof role !== "string") return member;
   if (typeof role !== "string") return member;
   return {
   return {
     ...member,
     ...member,

+ 2 - 2
web/src/hooks/useApps.ts

@@ -1,9 +1,9 @@
 import { useApi } from "./useApi";
 import { useApi } from "./useApi";
 import { apiClient } from "@/api/client";
 import { apiClient } from "@/api/client";
-import type { AppResponse } from "@/types";
+import type { SessionAppResponse } from "@/types";
 
 
 async function listApps() {
 async function listApps() {
-  const { data } = await apiClient.get<AppResponse[]>("/apps");
+  const { data } = await apiClient.get<SessionAppResponse[]>("/apps");
   return data;
   return data;
 }
 }
 
 

+ 4 - 0
web/src/lib/constants.ts

@@ -5,6 +5,7 @@ import {
   BrainCircuit,
   BrainCircuit,
   LayoutDashboard,
   LayoutDashboard,
   MessageSquare,
   MessageSquare,
+  Package,
   Puzzle,
   Puzzle,
   Users,
   Users,
   Wrench,
   Wrench,
@@ -22,6 +23,7 @@ export const ROUTE_PATHS = {
   teams: "/teams",
   teams: "/teams",
   skills: "/skills",
   skills: "/skills",
   models: "/models",
   models: "/models",
+  apps: "/apps",
   settings: "/settings",
   settings: "/settings",
 } as const;
 } as const;
 
 
@@ -34,6 +36,7 @@ export const NAV_ITEMS: Array<{ labelKey: string; path: string; icon: LucideIcon
   { labelKey: "nav.knowledge", path: ROUTE_PATHS.knowledge, icon: BookOpen },
   { labelKey: "nav.knowledge", path: ROUTE_PATHS.knowledge, icon: BookOpen },
   { labelKey: "nav.teams", path: ROUTE_PATHS.teams, icon: Users },
   { labelKey: "nav.teams", path: ROUTE_PATHS.teams, icon: Users },
   { labelKey: "nav.skills", path: ROUTE_PATHS.skills, icon: Puzzle },
   { labelKey: "nav.skills", path: ROUTE_PATHS.skills, icon: Puzzle },
+  { labelKey: "nav.apps", path: ROUTE_PATHS.apps, icon: Package },
   { labelKey: "nav.models", path: ROUTE_PATHS.models, icon: BrainCircuit },
   { labelKey: "nav.models", path: ROUTE_PATHS.models, icon: BrainCircuit },
 ];
 ];
 
 
@@ -53,6 +56,7 @@ export const STATUS_COLOR_MAP: Record<string, string> = {
   completed: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
   completed: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
   active: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
   active: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
   ok: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
   ok: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
+  published: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
   failed: "border-red-500/35 bg-red-500/10 text-red-800 dark:text-red-300",
   failed: "border-red-500/35 bg-red-500/10 text-red-800 dark:text-red-300",
   disabled: "border-zinc-500/35 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
   disabled: "border-zinc-500/35 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
   draft: "border-zinc-500/35 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
   draft: "border-zinc-500/35 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",

+ 59 - 1
web/src/locales/en.json

@@ -106,6 +106,7 @@
     "teams": "Teams",
     "teams": "Teams",
     "skills": "Skills",
     "skills": "Skills",
     "models": "Model Providers",
     "models": "Model Providers",
+    "apps": "Apps",
     "settings": "Settings",
     "settings": "Settings",
     "collapse": "Collapse",
     "collapse": "Collapse",
     "skipToContent": "Skip to main content",
     "skipToContent": "Skip to main content",
@@ -113,7 +114,7 @@
     "closeNavigation": "Close navigation"
     "closeNavigation": "Close navigation"
   },
   },
   "app": {
   "app": {
-    "name": "Auto Platform",
+    "name": "AgentDock",
     "loadingStudio": "Loading studio"
     "loadingStudio": "Loading studio"
   },
   },
   "demo": {
   "demo": {
@@ -1224,6 +1225,63 @@
     "checkGatewayConnection": "Check the gateway connection and credentials.",
     "checkGatewayConnection": "Check the gateway connection and credentials.",
     "somethingBroke": "Something broke"
     "somethingBroke": "Something broke"
   },
   },
+  "apps": {
+    "title": "Apps",
+    "description": "Configure, publish, and manage application open capabilities.",
+    "newApp": "New App",
+    "appDirectory": "App Directory",
+    "searchPlaceholder": "Search apps...",
+    "filterByStatus": "Filter by status",
+    "allStatuses": "All statuses",
+    "published": "Published",
+    "disabled": "Disabled",
+    "noMatchingApps": "No matching apps",
+    "adjustFilters": "Adjust search or filters to find a matching app.",
+    "noApps": "No apps",
+    "createAppStart": "Create an app to expose agent capabilities as APIs.",
+    "selectApp": "Select an app to view details.",
+    "noDescription": "No description",
+    "createApp": "Create App",
+    "createAppDescription": "Bind an agent or team to an application, generate API keys, and expose it externally.",
+    "namePlaceholder": "Customer Support",
+    "codePlaceholder": "customer_support",
+    "code": "Code",
+    "targetType": "Target Type",
+    "targetId": "Target ID",
+    "selectTarget": "Select a target...",
+    "appCreated": "App Created",
+    "copyKeyNow": "Copy this key now. The full secret is only shown once.",
+    "keyCopied": "API key copied",
+    "config": "Configuration",
+    "apiKeys": "API Keys",
+    "audits": "Audit Logs",
+    "apiUsage": "API Usage",
+    "publish": "Publish",
+    "disable": "Disable",
+    "republish": "Republish",
+    "publishedSuccess": "App published",
+    "disabledSuccess": "App disabled",
+    "updateSuccess": "App updated",
+    "basicConfig": "Basic Configuration",
+    "syncEndpoint": "Sync Endpoint",
+    "streamEndpoint": "Stream Endpoint",
+    "createKey": "Create Key",
+    "keyNamePlaceholder": "Production key",
+    "scopes": "Scopes",
+    "keyDisabled": "API key disabled",
+    "disableKey": "Disable",
+    "noKeys": "No API keys",
+    "createKeyStart": "Create an API key to allow external access.",
+    "apiKeyCreated": "API Key Created",
+    "noAudits": "No audit records",
+    "noAuditsDesc": "Audit records will appear here after the first API call.",
+    "records": "records",
+    "invokeType": "Type",
+    "duration": "Duration",
+    "keyPrefix": "Key",
+    "error": "Error",
+    "lastUsed": "Last used"
+  },
   "models": {
   "models": {
     "title": "Models",
     "title": "Models",
     "description": "Manage configured model endpoints.",
     "description": "Manage configured model endpoints.",

+ 59 - 1
web/src/locales/zh.json

@@ -106,6 +106,7 @@
     "teams": "团队",
     "teams": "团队",
     "skills": "技能",
     "skills": "技能",
     "models": "模型",
     "models": "模型",
+    "apps": "应用",
     "settings": "设置",
     "settings": "设置",
     "collapse": "收起",
     "collapse": "收起",
     "skipToContent": "跳到主内容",
     "skipToContent": "跳到主内容",
@@ -113,7 +114,7 @@
     "closeNavigation": "关闭导航"
     "closeNavigation": "关闭导航"
   },
   },
   "app": {
   "app": {
-    "name": "Auto Platform",
+    "name": "AgentDock",
     "loadingStudio": "正在加载工作台"
     "loadingStudio": "正在加载工作台"
   },
   },
   "demo": {
   "demo": {
@@ -1224,6 +1225,63 @@
     "checkGatewayConnection": "请检查网关连接和凭据配置。",
     "checkGatewayConnection": "请检查网关连接和凭据配置。",
     "somethingBroke": "页面发生错误"
     "somethingBroke": "页面发生错误"
   },
   },
+  "apps": {
+    "title": "应用",
+    "description": "配置、发布和管理应用开放能力。",
+    "newApp": "新建应用",
+    "appDirectory": "应用目录",
+    "searchPlaceholder": "搜索应用...",
+    "filterByStatus": "按状态筛选",
+    "allStatuses": "全部状态",
+    "published": "已发布",
+    "disabled": "已停用",
+    "noMatchingApps": "没有匹配的应用",
+    "adjustFilters": "调整搜索或筛选条件。",
+    "noApps": "暂无应用",
+    "createAppStart": "创建应用以将智能体能力开放为 API。",
+    "selectApp": "选择一个应用查看详情。",
+    "noDescription": "暂无描述",
+    "createApp": "创建应用",
+    "createAppDescription": "绑定 Agent 或 Team 到应用,生成 API Key 并对外开放调用。",
+    "namePlaceholder": "客户支持",
+    "codePlaceholder": "customer_support",
+    "code": "编码",
+    "targetType": "目标类型",
+    "targetId": "目标 ID",
+    "selectTarget": "选择目标...",
+    "appCreated": "应用已创建",
+    "copyKeyNow": "请立即复制此密钥,明文仅显示一次。",
+    "keyCopied": "API Key 已复制",
+    "config": "配置",
+    "apiKeys": "API Key",
+    "audits": "调用审计",
+    "apiUsage": "接口调用",
+    "publish": "发布",
+    "disable": "停用",
+    "republish": "重新发布",
+    "publishedSuccess": "应用已发布",
+    "disabledSuccess": "应用已停用",
+    "updateSuccess": "应用已更新",
+    "basicConfig": "基础配置",
+    "syncEndpoint": "同步接口",
+    "streamEndpoint": "流式接口",
+    "createKey": "创建 Key",
+    "keyNamePlaceholder": "生产环境 Key",
+    "scopes": "权限范围",
+    "keyDisabled": "API Key 已禁用",
+    "disableKey": "禁用",
+    "noKeys": "暂无 API Key",
+    "createKeyStart": "创建 API Key 以允许外部调用。",
+    "apiKeyCreated": "API Key 已创建",
+    "noAudits": "暂无审计记录",
+    "noAuditsDesc": "首次 API 调用后审计记录将出现在此处。",
+    "records": "条记录",
+    "invokeType": "类型",
+    "duration": "耗时",
+    "keyPrefix": "Key",
+    "error": "错误",
+    "lastUsed": "最近使用"
+  },
   "models": {
   "models": {
     "title": "模型",
     "title": "模型",
     "description": "管理已配置的模型接入。",
     "description": "管理已配置的模型接入。",

+ 194 - 0
web/src/pages/apps/AppsPage.tsx

@@ -0,0 +1,194 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import {
+  Clock,
+  Package,
+  RefreshCw,
+  Search,
+  SlidersHorizontal,
+} from "lucide-react";
+import { listApps } from "@/api/apps";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { SearchInput } from "@/components/shared/SearchInput";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Select } from "@/components/ui/select";
+import { cn, relativeTime } from "@/lib/utils";
+import type { AppDefinition, AppStatus } from "@/types";
+import { AppDetail } from "./components/AppDetail";
+import { CreateAppDialog } from "./components/CreateAppDialog";
+
+type StatusFilter = "all" | AppStatus;
+
+export function AppsPage() {
+  const { t } = useTranslation();
+  const [apps, setApps] = React.useState<AppDefinition[]>([]);
+  const [selectedAppId, setSelectedAppId] = React.useState<string>();
+  const [search, setSearch] = React.useState("");
+  const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
+  const [loading, setLoading] = React.useState(true);
+  const [error, setError] = React.useState<string>();
+  const [createOpen, setCreateOpen] = React.useState(false);
+
+  const selectedApp = apps.find((app) => app.id === selectedAppId);
+
+  const filtered = apps.filter((app) => {
+    const text = `${app.name} ${app.code} ${app.description ?? ""}`.toLowerCase();
+    const matchesSearch = text.includes(search.toLowerCase());
+    const matchesStatus = statusFilter === "all" || app.status === statusFilter;
+    return matchesSearch && matchesStatus;
+  });
+
+  const hasFilters = search.length > 0 || statusFilter !== "all";
+
+  const load = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      const data = await listApps();
+      setApps(data);
+      setSelectedAppId((current) => current ?? data[0]?.id);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
+    } finally {
+      setLoading(false);
+    }
+  }, [t]);
+
+  React.useEffect(() => { void load(); }, [load]);
+  React.useEffect(() => {
+    if (!selectedAppId && apps[0]) setSelectedAppId(apps[0].id);
+  }, [selectedAppId, apps]);
+
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+
+  return (
+    <div className="flex min-h-0 flex-col gap-6">
+      <PageHeader
+        title={t("apps.title")}
+        description={t("apps.description")}
+        actions={
+          <>
+            <Button variant="outline" onClick={() => void load()}>
+              <RefreshCw className="h-4 w-4" /> {t("common.refresh")}
+            </Button>
+            <Button onClick={() => setCreateOpen(true)}>
+              <Package className="h-4 w-4" /> {t("apps.newApp")}
+            </Button>
+          </>
+        }
+      />
+
+      <div className="grid h-[calc(100dvh-180px)] min-h-[620px] gap-5 xl:grid-cols-[320px_minmax(0,1fr)]">
+        <Card className="min-h-0 overflow-hidden">
+          <CardHeader className="p-4">
+            <div className="flex items-start justify-between gap-3">
+              <div>
+                <CardTitle>{t("apps.appDirectory")}</CardTitle>
+                <CardDescription>
+                  {filtered.length} / {apps.length}
+                </CardDescription>
+              </div>
+              <SlidersHorizontal className="mt-1 h-4 w-4 text-muted-foreground" />
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-3 p-4 pt-0">
+            <SearchInput value={search} onChange={setSearch} placeholder={t("apps.searchPlaceholder")} />
+            <div className="grid gap-3">
+              <Select
+                aria-label={t("apps.filterByStatus")}
+                value={statusFilter}
+                onChange={(event) => setStatusFilter(event.target.value as StatusFilter)}
+                options={[
+                  { value: "all", label: t("apps.allStatuses") },
+                  { value: "draft", label: t("common.draft") },
+                  { value: "published", label: t("apps.published") },
+                  { value: "disabled", label: t("apps.disabled") },
+                ]}
+              />
+            </div>
+            {hasFilters ? (
+              <Button type="button" variant="ghost" size="sm" onClick={() => { setSearch(""); setStatusFilter("all"); }}>
+                {t("common.clearFilters")}
+              </Button>
+            ) : null}
+
+            {filtered.length ? (
+              <div className="max-h-[calc(100vh-390px)] space-y-2 overflow-auto pr-1">
+                {filtered.map((app) => (
+                  <button
+                    key={app.id}
+                    type="button"
+                    onClick={() => setSelectedAppId(app.id)}
+                    className={cn(
+                      "w-full rounded-md border border-border bg-muted/30 p-4 text-left transition hover:bg-muted/55 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
+                      app.id === selectedAppId && "border-primary/45 bg-primary/10 shadow-glow",
+                    )}
+                  >
+                    <div className="flex items-start justify-between gap-3">
+                      <div className="min-w-0">
+                        <div className="flex items-center gap-2">
+                          <Package className="h-4 w-4 text-primary" />
+                          <p className="truncate text-sm font-semibold">{app.name}</p>
+                        </div>
+                        <p className="mt-1 truncate text-xs text-muted-foreground">{app.code}</p>
+                      </div>
+                      <StatusBadge status={app.status} />
+                    </div>
+                    <p className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">
+                      {app.description ?? t("apps.noDescription")}
+                    </p>
+                    <div className="mt-3 flex flex-wrap items-center gap-2">
+                      <Badge className="border-border bg-surface-elevated text-muted-foreground">{app.target_type}</Badge>
+                      <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
+                        <Clock className="h-3.5 w-3.5" /> {relativeTime(app.created_time)}
+                      </span>
+                    </div>
+                  </button>
+                ))}
+              </div>
+            ) : (
+              <EmptyState icon={Search} title={t("apps.noMatchingApps")} description={t("apps.adjustFilters")} />
+            )}
+          </CardContent>
+        </Card>
+
+        <div className="min-h-0 min-w-0">
+          <Card className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
+            {selectedApp ? (
+              <AppDetail app={selectedApp} onUpdated={(updated) => {
+                setApps((current) => current.map((a) => (a.id === updated.id ? updated : a)));
+              }} />
+            ) : (
+              <>
+                <CardHeader className="border-b border-border bg-muted/15 p-5">
+                  <CardTitle>{t("apps.title")}</CardTitle>
+                  <CardDescription>{t("apps.selectApp")}</CardDescription>
+                </CardHeader>
+                <CardContent className="p-6">
+                  <EmptyState icon={Package} title={t("apps.noApps")} description={t("apps.createAppStart")} actionLabel={t("apps.newApp")} onAction={() => setCreateOpen(true)} />
+                </CardContent>
+              </>
+            )}
+          </Card>
+        </div>
+      </div>
+
+      <CreateAppDialog
+        open={createOpen}
+        onOpenChange={setCreateOpen}
+        onCreated={(app) => {
+          setSelectedAppId(app.id);
+          setSearch("");
+          void load();
+        }}
+      />
+    </div>
+  );
+}

+ 173 - 0
web/src/pages/apps/components/AppApiKeysPanel.tsx

@@ -0,0 +1,173 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { Key } from "lucide-react";
+import { createAppApiKey, listAppApiKeys, updateAppApiKeyStatus } from "@/api/apps";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { toast } from "@/components/ui/toaster";
+import type { AppApiKeyResponse } from "@/types";
+
+export function AppApiKeysPanel({ appId }: { appId: string }) {
+  const { t } = useTranslation();
+  const [keys, setKeys] = React.useState<AppApiKeyResponse[]>([]);
+  const [loading, setLoading] = React.useState(true);
+  const [createOpen, setCreateOpen] = React.useState(false);
+  const [revealKey, setRevealKey] = React.useState<string>();
+
+  const loadKeys = React.useCallback(async () => {
+    try {
+      const data = await listAppApiKeys(appId);
+      setKeys(data);
+    } catch {
+      toast.error(t("errors.failedToLoad"));
+    } finally {
+      setLoading(false);
+    }
+  }, [appId, t]);
+
+  React.useEffect(() => { void loadKeys(); }, [loadKeys]);
+
+  const handleDisable = React.useCallback(async (keyId: string) => {
+    try {
+      await updateAppApiKeyStatus(appId, keyId, "disabled");
+      toast.success(t("apps.keyDisabled"));
+      void loadKeys();
+    } catch {
+      toast.error(t("errors.failedToUpdate"));
+    }
+  }, [appId, loadKeys, t]);
+
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold">{t("apps.apiKeys")}</h3>
+        <Button size="sm" onClick={() => setCreateOpen(true)}>
+          {t("apps.createKey")}
+        </Button>
+      </div>
+
+      {keys.length ? (
+        <div className="space-y-2">
+          {keys.map((key) => (
+            <div key={key.id} className="flex items-center justify-between rounded-md border border-border p-3">
+              <div className="min-w-0">
+                <div className="flex items-center gap-2">
+                  <Key className="h-4 w-4 text-muted-foreground" />
+                  <span className="text-sm font-medium">{key.name}</span>
+                  <StatusBadge status={key.status} />
+                </div>
+                <div className="mt-1 flex gap-3 text-xs text-muted-foreground">
+                  <span>{key.key_prefix}...</span>
+                  {key.scopes && <span>{key.scopes}</span>}
+                  {key.last_used_time && <span>{t("apps.lastUsed")}: {new Date(key.last_used_time).toLocaleString()}</span>}
+                  <span>{t("common.created")}: {new Date(key.created_time).toLocaleString()}</span>
+                </div>
+              </div>
+              {key.status === "active" && (
+                <Button size="sm" variant="outline" onClick={() => void handleDisable(key.id)}>
+                  {t("apps.disableKey")}
+                </Button>
+              )}
+            </div>
+          ))}
+        </div>
+      ) : (
+        <EmptyState icon={Key} title={t("apps.noKeys")} description={t("apps.createKeyStart")} />
+      )}
+
+      <CreateAppApiKeyDialog
+        open={createOpen}
+        onOpenChange={setCreateOpen}
+        appId={appId}
+        onCreated={(keyText) => {
+          setRevealKey(keyText);
+          void loadKeys();
+        }}
+      />
+
+      <Dialog open={!!revealKey} onOpenChange={() => setRevealKey(undefined)}>
+        <div className="w-[480px] space-y-4">
+          <h2 className="text-lg font-semibold">{t("apps.apiKeyCreated")}</h2>
+          <p className="text-sm text-muted-foreground">{t("apps.copyKeyNow")}</p>
+          <div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
+            <code className="block break-all text-sm">{revealKey}</code>
+          </div>
+          <div className="flex justify-end">
+            <Button onClick={() => {
+              navigator.clipboard.writeText(revealKey!).then(() => toast.success(t("apps.keyCopied")));
+            }}>
+              {t("common.copy")}
+            </Button>
+          </div>
+        </div>
+      </Dialog>
+    </div>
+  );
+}
+
+function CreateAppApiKeyDialog({
+  open,
+  onOpenChange,
+  appId,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  appId: string;
+  onCreated: (apiKey: string) => void;
+}) {
+  const { t } = useTranslation();
+  const [name, setName] = React.useState("");
+  const [scopes, setScopes] = React.useState("app:invoke app:stream");
+  const [saving, setSaving] = React.useState(false);
+
+  const handleSubmit = React.useCallback(async () => {
+    if (!name.trim()) return;
+    setSaving(true);
+    try {
+      const resp = await createAppApiKey(appId, {
+        name: name.trim(),
+        scopes: scopes || null,
+      });
+      onCreated(resp.api_key);
+      setName("");
+      onOpenChange(false);
+    } catch {
+      toast.error(t("errors.failedToCreate"));
+    } finally {
+      setSaving(false);
+    }
+  }, [appId, name, scopes, onCreated, onOpenChange, t]);
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <div className="w-[420px] space-y-4">
+        <h2 className="text-lg font-semibold">{t("apps.createKey")}</h2>
+        <div className="space-y-3">
+          <div>
+            <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("common.name")} *</label>
+            <Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("apps.keyNamePlaceholder")} />
+          </div>
+          <div>
+            <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("apps.scopes")}</label>
+            <Input value={scopes} onChange={(e) => setScopes(e.target.value)} placeholder="app:invoke app:stream" />
+          </div>
+        </div>
+        <div className="flex justify-end gap-2">
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {t("common.cancel")}
+          </Button>
+          <Button disabled={saving || !name.trim()} onClick={() => void handleSubmit()}>
+            {saving ? t("common.creating") : t("common.create")}
+          </Button>
+        </div>
+      </div>
+    </Dialog>
+  );
+}

+ 69 - 0
web/src/pages/apps/components/AppAuditsPanel.tsx

@@ -0,0 +1,69 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { FileText } from "lucide-react";
+import { listAppAudits } from "@/api/apps";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { toast } from "@/components/ui/toaster";
+import type { AppInvocationAuditResponse } from "@/types";
+
+export function AppAuditsPanel({ appId }: { appId: string }) {
+  const { t } = useTranslation();
+  const [audits, setAudits] = React.useState<AppInvocationAuditResponse[]>([]);
+  const [loading, setLoading] = React.useState(true);
+
+  const loadAudits = React.useCallback(async () => {
+    try {
+      const data = await listAppAudits(appId, 50);
+      setAudits(data);
+    } catch {
+      toast.error(t("errors.failedToLoad"));
+    } finally {
+      setLoading(false);
+    }
+  }, [appId, t]);
+
+  React.useEffect(() => { void loadAudits(); }, [loadAudits]);
+
+  if (loading) return <LoadingSpinner label={t("common.loading")} />;
+
+  if (!audits.length) {
+    return <EmptyState icon={FileText} title={t("apps.noAudits")} description={t("apps.noAuditsDesc")} />;
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold">{t("apps.audits")}</h3>
+        <span className="text-xs text-muted-foreground">{audits.length} {t("apps.records")}</span>
+      </div>
+      <div className="overflow-auto">
+        <table className="w-full text-sm">
+          <thead>
+            <tr className="border-b border-border text-left text-xs text-muted-foreground">
+              <th className="pb-2 pr-3 font-medium">{t("common.created")}</th>
+              <th className="pb-2 pr-3 font-medium">{t("common.status")}</th>
+              <th className="pb-2 pr-3 font-medium">{t("apps.invokeType")}</th>
+              <th className="pb-2 pr-3 font-medium">{t("apps.duration")}</th>
+              <th className="pb-2 pr-3 font-medium">{t("apps.keyPrefix")}</th>
+              <th className="pb-2 font-medium">{t("apps.error")}</th>
+            </tr>
+          </thead>
+          <tbody>
+            {audits.map((audit) => (
+              <tr key={audit.id} className="border-b border-border/50">
+                <td className="py-2 pr-3 text-xs">{new Date(audit.created_time).toLocaleString()}</td>
+                <td className="py-2 pr-3"><StatusBadge status={audit.status} /></td>
+                <td className="py-2 pr-3 text-xs">{audit.invoke_type}</td>
+                <td className="py-2 pr-3 text-xs">{audit.duration_ms}ms</td>
+                <td className="py-2 pr-3 text-xs font-mono">{audit.api_key_prefix ?? "-"}</td>
+                <td className="py-2 text-xs text-red-500">{audit.error_message ?? "-"}</td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+    </div>
+  );
+}

+ 222 - 0
web/src/pages/apps/components/AppDetail.tsx

@@ -0,0 +1,222 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { Pencil } from "lucide-react";
+import { updateApp, updateAppStatus } from "@/api/apps";
+import { Button } from "@/components/ui/button";
+import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Tabs } from "@/components/ui/tabs";
+import { toast } from "@/components/ui/toaster";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import type { AppDefinition } from "@/types";
+import { AppApiKeysPanel } from "./AppApiKeysPanel";
+import { AppAuditsPanel } from "./AppAuditsPanel";
+
+type DetailTab = "config" | "apiKeys" | "audits" | "usage";
+
+export function AppDetail({ app, onUpdated }: { app: AppDefinition; onUpdated: (app: AppDefinition) => void }) {
+  const { t } = useTranslation();
+  const [tab, setTab] = React.useState<DetailTab>("config");
+
+  const handlePublish = React.useCallback(async () => {
+    try {
+      const updated = await updateAppStatus(app.id, "published");
+      onUpdated(updated);
+      toast.success(t("apps.publishedSuccess"));
+    } catch {
+      toast.error(t("errors.failedToUpdate"));
+    }
+  }, [app.id, onUpdated, t]);
+
+  const handleDisable = React.useCallback(async () => {
+    try {
+      const updated = await updateAppStatus(app.id, "disabled");
+      onUpdated(updated);
+      toast.success(t("apps.disabledSuccess"));
+    } catch {
+      toast.error(t("errors.failedToUpdate"));
+    }
+  }, [app.id, onUpdated, t]);
+
+  return (
+    <>
+      <CardHeader className="border-b border-border bg-muted/15 p-5">
+        <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
+          <div className="min-w-0">
+            <div className="flex min-w-0 flex-wrap items-center gap-2">
+              <CardTitle className="truncate text-lg">{app.name}</CardTitle>
+              <StatusBadge status={app.status} />
+            </div>
+            <CardDescription className="mt-1">{app.code} &middot; {app.target_type}/{app.target_id}</CardDescription>
+          </div>
+          <div className="flex flex-wrap items-center gap-2">
+            {app.status === "draft" && (
+              <Button size="sm" onClick={() => void handlePublish()}>
+                {t("apps.publish")}
+              </Button>
+            )}
+            {app.status === "published" && (
+              <Button size="sm" variant="outline" onClick={() => void handleDisable()}>
+                {t("apps.disable")}
+              </Button>
+            )}
+            {app.status === "disabled" && (
+              <Button size="sm" onClick={() => void handlePublish()}>
+                {t("apps.republish")}
+              </Button>
+            )}
+          </div>
+        </div>
+      </CardHeader>
+      <CardContent className="min-h-0 flex-1 p-5">
+        <Tabs
+          value={tab}
+          onChange={(value) => setTab(value as DetailTab)}
+          tabs={[
+            {
+              value: "config",
+              label: t("apps.config"),
+              content: <AppConfigPanel app={app} onUpdated={onUpdated} />,
+            },
+            {
+              value: "apiKeys",
+              label: t("apps.apiKeys"),
+              content: <AppApiKeysPanel appId={app.id} />,
+            },
+            {
+              value: "audits",
+              label: t("apps.audits"),
+              content: <AppAuditsPanel appId={app.id} />,
+            },
+            {
+              value: "usage",
+              label: t("apps.apiUsage"),
+              content: <AppUsagePanel app={app} />,
+            },
+          ]}
+        />
+      </CardContent>
+    </>
+  );
+}
+
+function AppConfigPanel({ app, onUpdated }: { app: AppDefinition; onUpdated: (app: AppDefinition) => void }) {
+  const { t } = useTranslation();
+  const [editing, setEditing] = React.useState(false);
+  const [name, setName] = React.useState(app.name);
+  const [description, setDescription] = React.useState(app.description ?? "");
+  const [saving, setSaving] = React.useState(false);
+
+  const handleSave = React.useCallback(async () => {
+    setSaving(true);
+    try {
+      const updated = await updateApp(app.id, { name, description: description || null });
+      onUpdated(updated);
+      setEditing(false);
+      toast.success(t("apps.updateSuccess"));
+    } catch {
+      toast.error(t("errors.failedToUpdate"));
+    } finally {
+      setSaving(false);
+    }
+  }, [app.id, name, description, onUpdated, t]);
+
+  const fields = [
+    { label: t("common.name"), value: editing ? undefined : app.name },
+    { label: t("apps.code"), value: app.code },
+    { label: t("common.description"), value: editing ? undefined : (app.description ?? "-") },
+    { label: t("apps.targetType"), value: app.target_type },
+    { label: t("apps.targetId"), value: app.target_id },
+    { label: t("common.status"), value: app.status },
+    { label: t("common.created"), value: new Date(app.created_time).toLocaleString() },
+    { label: t("common.updated"), value: new Date(app.updated_time).toLocaleString() },
+  ];
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold">{t("apps.basicConfig")}</h3>
+        {!editing && (
+          <Button size="sm" variant="outline" onClick={() => setEditing(true)}>
+            <Pencil className="h-4 w-4" /> {t("common.edit")}
+          </Button>
+        )}
+      </div>
+
+      {editing ? (
+        <div className="space-y-3">
+          <div>
+            <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("common.name")}</label>
+            <input
+              className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+            />
+          </div>
+          <div>
+            <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("common.description")}</label>
+            <textarea
+              className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
+              rows={3}
+              value={description}
+              onChange={(e) => setDescription(e.target.value)}
+            />
+          </div>
+          <div className="flex gap-2">
+            <Button size="sm" disabled={saving} onClick={() => void handleSave()}>
+              {saving ? t("common.creating") : t("common.save")}
+            </Button>
+            <Button size="sm" variant="outline" onClick={() => setEditing(false)}>
+              {t("common.cancel")}
+            </Button>
+          </div>
+        </div>
+      ) : (
+        <div className="grid gap-3 text-sm">
+          {fields.map((field) => (
+            <div key={field.label} className="grid grid-cols-[140px_minmax(0,1fr)] gap-2">
+              <span className="text-muted-foreground">{field.label}</span>
+              <span className="font-medium">{field.value}</span>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function AppUsagePanel({ app }: { app: AppDefinition }) {
+  const { t } = useTranslation();
+  const baseUrl = `${window.location.origin}/gateway/openapi/apps/${app.code}`;
+
+  const syncExample = `curl -X POST "${baseUrl}/chat" \\
+  -H "Content-Type: application/json" \\
+  -H "Authorization: Bearer agp_YOUR_API_KEY" \\
+  -d '{
+    "message": "Hello",
+    "user_id": "user-001"
+  }'`;
+
+  const streamExample = `curl -X POST "${baseUrl}/chat/stream" \\
+  -H "Content-Type: application/json" \\
+  -H "Accept: text/event-stream" \\
+  -H "Authorization: Bearer agp_YOUR_API_KEY" \\
+  -d '{
+    "message": "Hello",
+    "user_id": "user-001"
+  }'`;
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h3 className="mb-2 text-sm font-semibold">{t("apps.syncEndpoint")}</h3>
+        <code className="block rounded-md bg-muted p-3 text-xs">{baseUrl}/chat</code>
+        <pre className="mt-2 max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs">{syncExample}</pre>
+      </div>
+      <div>
+        <h3 className="mb-2 text-sm font-semibold">{t("apps.streamEndpoint")}</h3>
+        <code className="block rounded-md bg-muted p-3 text-xs">{baseUrl}/chat/stream</code>
+        <pre className="mt-2 max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs">{streamExample}</pre>
+      </div>
+    </div>
+  );
+}

+ 174 - 0
web/src/pages/apps/components/CreateAppDialog.tsx

@@ -0,0 +1,174 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import { listAgents } from "@/api/agents";
+import { listTeams } from "@/api";
+import { createApp, createAppApiKey } from "@/api/apps";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { toast } from "@/components/ui/toaster";
+import type { AgentDefinition, AppDefinition, AppTargetType, TeamDefinition } from "@/types";
+
+export function CreateAppDialog({
+  open,
+  onOpenChange,
+  onCreated,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onCreated: (app: AppDefinition) => void;
+}) {
+  const { t } = useTranslation();
+  const [step, setStep] = React.useState<"form" | "key">("form");
+  const [saving, setSaving] = React.useState(false);
+  const [name, setName] = React.useState("");
+  const [code, setCode] = React.useState("");
+  const [description, setDescription] = React.useState("");
+  const [targetType, setTargetType] = React.useState<AppTargetType>("agent");
+  const [targetId, setTargetId] = React.useState("");
+  const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
+  const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
+  const [createdApp, setCreatedApp] = React.useState<AppDefinition>();
+  const [apiKey, setApiKey] = React.useState("");
+
+  React.useEffect(() => {
+    if (open) {
+      setStep("form");
+      setName("");
+      setCode("");
+      setDescription("");
+      setTargetType("agent");
+      setTargetId("");
+      setCreatedApp(undefined);
+      setApiKey("");
+      Promise.all([
+        listAgents().catch(() => [] as AgentDefinition[]),
+        listTeams().catch(() => [] as TeamDefinition[]),
+      ]).then(([a, t]) => {
+        setAgents(a);
+        setTeams(t);
+      });
+    }
+  }, [open]);
+
+  React.useEffect(() => {
+    if (name && !code) {
+      setCode(name.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_").slice(0, 64));
+    }
+  }, [name, code]);
+
+  const targets = targetType === "agent" ? agents : teams;
+
+  const handleSubmit = React.useCallback(async () => {
+    if (!name.trim() || !code.trim() || !targetId) return;
+    setSaving(true);
+    try {
+      const app = await createApp({
+        code: code.trim(),
+        name: name.trim(),
+        description: description.trim() || null,
+        target_type: targetType,
+        target_id: targetId,
+      });
+      const keyResp = await createAppApiKey(app.id, {
+        name: "default",
+        scopes: "app:invoke app:stream",
+      });
+      setCreatedApp(app);
+      setApiKey(keyResp.api_key);
+      setStep("key");
+      onCreated(app);
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : t("errors.failedToCreate"));
+    } finally {
+      setSaving(false);
+    }
+  }, [name, code, description, targetType, targetId, onCreated, t]);
+
+  const handleCopyKey = React.useCallback(() => {
+    navigator.clipboard.writeText(apiKey).then(() => toast.success(t("apps.keyCopied")));
+  }, [apiKey, t]);
+
+  const handleClose = React.useCallback(() => {
+    onOpenChange(false);
+  }, [onOpenChange]);
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <div className="w-[520px] space-y-5">
+        {step === "form" ? (
+          <>
+            <h2 className="text-lg font-semibold">{t("apps.createApp")}</h2>
+            <p className="text-sm text-muted-foreground">{t("apps.createAppDescription")}</p>
+            <div className="space-y-3">
+              <div>
+                <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("common.name")} *</label>
+                <Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("apps.namePlaceholder")} />
+              </div>
+              <div>
+                <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("apps.code")} *</label>
+                <Input value={code} onChange={(e) => setCode(e.target.value)} placeholder={t("apps.codePlaceholder")} />
+              </div>
+              <div>
+                <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("common.description")}</label>
+                <textarea
+                  className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
+                  rows={2}
+                  value={description}
+                  onChange={(e) => setDescription(e.target.value)}
+                />
+              </div>
+              <div>
+                <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("apps.targetType")} *</label>
+                <Select
+                  value={targetType}
+                  onChange={(e) => { setTargetType(e.target.value as AppTargetType); setTargetId(""); }}
+                  options={[
+                    { value: "agent", label: "Agent" },
+                    { value: "team", label: "Team" },
+                  ]}
+                />
+              </div>
+              <div>
+                <label className="mb-1 block text-xs font-medium text-muted-foreground">{t("apps.targetId")} *</label>
+                <Select
+                  value={targetId}
+                  onChange={(e) => setTargetId(e.target.value)}
+                  options={[
+                    { value: "", label: t("apps.selectTarget") },
+                    ...targets.map((target) => ({ value: target.id, label: target.name })),
+                  ]}
+                />
+              </div>
+            </div>
+            <div className="flex justify-end gap-2">
+              <Button variant="outline" onClick={handleClose}>
+                {t("common.cancel")}
+              </Button>
+              <Button disabled={saving || !name.trim() || !code.trim() || !targetId} onClick={() => void handleSubmit()}>
+                {saving ? t("common.creating") : t("common.create")}
+              </Button>
+            </div>
+          </>
+        ) : (
+          <>
+            <h2 className="text-lg font-semibold">{t("apps.appCreated")}</h2>
+            <p className="text-sm text-muted-foreground">{t("apps.copyKeyNow")}</p>
+            <div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
+              <code className="block break-all text-sm">{apiKey}</code>
+            </div>
+            <div className="flex justify-end gap-2">
+              <Button variant="outline" onClick={handleCopyKey}>
+                {t("common.copy")}
+              </Button>
+              <Button onClick={handleClose}>
+                {t("common.close")}
+              </Button>
+            </div>
+          </>
+        )}
+      </div>
+    </Dialog>
+  );
+}

+ 58 - 8
web/src/pages/sessions/SessionChatPage.tsx

@@ -1,7 +1,7 @@
 import * as React from "react";
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { Info, MessageSquarePlus } from "lucide-react";
 import { Info, MessageSquarePlus } from "lucide-react";
-import { createMessage, listMessages, listRunRequests } from "@/api";
+import { executeSessionStream, listMessages, listRunRequests } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { PageHeader } from "@/components/shared/PageHeader";
 import { PageHeader } from "@/components/shared/PageHeader";
@@ -26,6 +26,8 @@ export function SessionChatPage() {
   const [runRequests, setRunRequests] = React.useState<RunRequest[]>([]);
   const [runRequests, setRunRequests] = React.useState<RunRequest[]>([]);
   const [createOpen, setCreateOpen] = React.useState(false);
   const [createOpen, setCreateOpen] = React.useState(false);
   const [contextOpen, setContextOpen] = React.useState(false);
   const [contextOpen, setContextOpen] = React.useState(false);
+  const [sending, setSending] = React.useState(false);
+  const [streamingText, setStreamingText] = React.useState<string | null>(null);
 
 
   React.useEffect(() => {
   React.useEffect(() => {
     if (!activeSessionId && sessions.data?.[0]) setActiveSessionId(sessions.data[0].id);
     if (!activeSessionId && sessions.data?.[0]) setActiveSessionId(sessions.data[0].id);
@@ -47,13 +49,55 @@ export function SessionChatPage() {
     `${session.title ?? ""} ${session.channel_type} ${session.user_id}`.toLowerCase().includes(search.toLowerCase()));
     `${session.title ?? ""} ${session.channel_type} ${session.user_id}`.toLowerCase().includes(search.toLowerCase()));
   const activeSession = (sessions.data ?? []).find((session) => session.id === activeSessionId);
   const activeSession = (sessions.data ?? []).find((session) => session.id === activeSessionId);
 
 
-  async function send(text: string) {
+  function send(text: string) {
     if (!activeSessionId) return;
     if (!activeSessionId) return;
-    await createMessage({ session_id: activeSessionId, role: "user", content_type: "text", content_text: text });
-    const [nextMessages, nextRuns] = await Promise.all([listMessages(activeSessionId), listRunRequests(activeSessionId)]);
-    setMessages(nextMessages);
-    setRunRequests(nextRuns);
-    toast.success(t("sessions.messageSent"));
+    const sessionId = activeSessionId;
+    setSending(true);
+    setStreamingText("");
+    setMessages((prev) => [...prev, {
+      id: "temp-" + Date.now(),
+      session_id: sessionId,
+      role: "user",
+      content_type: "text",
+      content_text: text,
+      created_time: new Date().toISOString(),
+    }]);
+
+    executeSessionStream(
+      { session_id: sessionId, message_text: text },
+      (event, data) => {
+        if (event === "agent.run.delta" || event === "team.run.delta") {
+          setStreamingText((prev) => (prev ?? "") + (data.text as string || ""));
+        } else if (event === "session.execute.completed") {
+          setStreamingText(null);
+          setSending(false);
+          if (data.request_status === "failed") {
+            toast.error((data.error_message as string) || t("sessions.runFailed", "Run failed"));
+          } else {
+            toast.success(t("sessions.messageSent"));
+          }
+          void Promise.all([
+            listMessages(sessionId),
+            listRunRequests(sessionId),
+            sessions.refetch(),
+          ]).then(([nextMessages, nextRuns]) => {
+            setMessages(nextMessages);
+            setRunRequests(nextRuns);
+          });
+        } else if (event === "session.execute.failed") {
+          setStreamingText(null);
+          setSending(false);
+          toast.error((data.error_message as string) || t("sessions.runFailed", "Run failed"));
+          void Promise.all([
+            listMessages(sessionId),
+            listRunRequests(sessionId),
+          ]).then(([nextMessages, nextRuns]) => {
+            setMessages(nextMessages);
+            setRunRequests(nextRuns);
+          });
+        }
+      },
+    );
   }
   }
 
 
   if (sessions.loading) return <LoadingSpinner label={t("common.loading")} />;
   if (sessions.loading) return <LoadingSpinner label={t("common.loading")} />;
@@ -97,7 +141,7 @@ export function SessionChatPage() {
             </Button>
             </Button>
           </CardHeader>
           </CardHeader>
           <CardContent className="p-0">
           <CardContent className="p-0">
-            <ChatPanel messages={messages} active={Boolean(activeSessionId)} onSend={(text) => void send(text)} />
+            <ChatPanel messages={messages} active={Boolean(activeSessionId)} disabled={sending} streamingText={streamingText} onSend={(text) => send(text)} />
           </CardContent>
           </CardContent>
         </Card>
         </Card>
       </div>
       </div>
@@ -122,6 +166,7 @@ export function SessionChatPage() {
               <ContextItem label={t("sessions.application")} value={appName(apps.data ?? [], activeSession.app_id, t("sessions.unknownApplication"))} />
               <ContextItem label={t("sessions.application")} value={appName(apps.data ?? [], activeSession.app_id, t("sessions.unknownApplication"))} />
               <ContextItem label={t("sessions.channelType")} value={formatChannel(activeSession.channel_type)} />
               <ContextItem label={t("sessions.channelType")} value={formatChannel(activeSession.channel_type)} />
               <ContextItem label={t("sessions.user")} value={activeSession.user_id} />
               <ContextItem label={t("sessions.user")} value={activeSession.user_id} />
+              <ContextItem label={t("sessions.runtimeTarget", "Runtime target")} value={formatTarget(activeSession, t("sessions.notConfigured", "Not configured"))} />
               <ContextItem label={t("sessions.lastActive")} value={formatDateTime(activeSession.last_active_time ?? activeSession.created_time)} />
               <ContextItem label={t("sessions.lastActive")} value={formatDateTime(activeSession.last_active_time ?? activeSession.created_time)} />
               <ContextItem label={t("sessions.messages")} value={String(messages.length)} />
               <ContextItem label={t("sessions.messages")} value={String(messages.length)} />
               <ContextItem label={t("sessions.runActivity")} value={String(runRequests.length)} />
               <ContextItem label={t("sessions.runActivity")} value={String(runRequests.length)} />
@@ -158,6 +203,11 @@ function appName(apps: Array<{ id: string; name: string }>, appId: string, fallb
   return apps.find((app) => app.id === appId)?.name ?? fallback;
   return apps.find((app) => app.id === appId)?.name ?? fallback;
 }
 }
 
 
+function formatTarget(session: Session, fallback: string) {
+  if (!session.runtime_target_type || !session.runtime_target_id) return fallback;
+  return `${formatChannel(session.runtime_target_type)} · ${session.runtime_target_id.slice(0, 8)}`;
+}
+
 function formatChannel(value: string) {
 function formatChannel(value: string) {
   return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
   return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
 }
 }

+ 26 - 3
web/src/pages/sessions/components/ChatPanel.tsx

@@ -8,10 +8,14 @@ import type { Message } from "@/types";
 export function ChatPanel({
 export function ChatPanel({
   messages,
   messages,
   active,
   active,
+  disabled = false,
+  streamingText,
   onSend,
   onSend,
 }: {
 }: {
   messages: Message[];
   messages: Message[];
   active: boolean;
   active: boolean;
+  disabled?: boolean;
+  streamingText?: string | null;
   onSend: (text: string) => void;
   onSend: (text: string) => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -19,8 +23,11 @@ export function ChatPanel({
     <section className="flex min-h-[560px] min-w-0 flex-1 flex-col overflow-hidden bg-surface-base">
     <section className="flex min-h-[560px] min-w-0 flex-1 flex-col overflow-hidden bg-surface-base">
       <div className="min-w-0 flex-1 space-y-3 overflow-auto p-3 sm:p-4">
       <div className="min-w-0 flex-1 space-y-3 overflow-auto p-3 sm:p-4">
         {active ? (
         {active ? (
-          messages.length ? (
-            messages.map((message) => <MessageBubble key={message.id} message={message} />)
+          messages.length > 0 || streamingText != null ? (
+            <>
+              {messages.map((message) => <MessageBubble key={message.id} message={message} />)}
+              {streamingText != null && <StreamingBubble text={streamingText} />}
+            </>
           ) : (
           ) : (
             <EmptyState icon={MessageCircle} title={t("sessions.noMessages")} description={t("sessions.sendFirstMessage")} />
             <EmptyState icon={MessageCircle} title={t("sessions.noMessages")} description={t("sessions.sendFirstMessage")} />
           )
           )
@@ -28,7 +35,23 @@ export function ChatPanel({
           <EmptyState icon={MessageCircle} title={t("sessions.noSessionSelected")} description={t("sessions.chooseOrCreate")} />
           <EmptyState icon={MessageCircle} title={t("sessions.noSessionSelected")} description={t("sessions.chooseOrCreate")} />
         )}
         )}
       </div>
       </div>
-      <ChatInput disabled={!active} onSend={onSend} />
+      <ChatInput disabled={!active || disabled} onSend={onSend} />
     </section>
     </section>
   );
   );
 }
 }
+
+function StreamingBubble({ text }: { text: string }) {
+  const { t } = useTranslation();
+  return (
+    <div className="flex min-w-0 justify-start">
+      <div className="min-w-0 max-w-[82%] rounded-md border border-border bg-surface-elevated px-3 py-2 text-sm">
+        <div className="mb-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
+          {t("sessions.roleAssistant")}
+        </div>
+        <p className="whitespace-pre-wrap break-words leading-6 [overflow-wrap:anywhere]">
+          {text}<span className="animate-pulse">|</span>
+        </p>
+      </div>
+    </div>
+  );
+}

+ 100 - 5
web/src/pages/sessions/components/CreateSessionDialog.tsx

@@ -1,13 +1,13 @@
 import * as React from "react";
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
-import { createSession } from "@/api";
+import { createSession, listAgentConfigs, listAgents, listTeamConfigs, listTeams } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
 import { Input } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
 import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { toast } from "@/components/ui/toaster";
 import { useAuthStore } from "@/stores/auth";
 import { useAuthStore } from "@/stores/auth";
-import type { AppResponse, Session } from "@/types";
+import type { AgentConfig, SessionAppResponse, Session, TeamConfig, TeamDefinition } from "@/types";
 
 
 export function CreateSessionDialog({
 export function CreateSessionDialog({
   open,
   open,
@@ -17,7 +17,7 @@ export function CreateSessionDialog({
 }: {
 }: {
   open: boolean;
   open: boolean;
   onOpenChange: (open: boolean) => void;
   onOpenChange: (open: boolean) => void;
-  apps: AppResponse[];
+  apps: SessionAppResponse[];
   onCreated: (session: Session) => void;
   onCreated: (session: Session) => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -25,17 +25,66 @@ export function CreateSessionDialog({
   const [appId, setAppId] = React.useState("");
   const [appId, setAppId] = React.useState("");
   const [title, setTitle] = React.useState("");
   const [title, setTitle] = React.useState("");
   const [channelType, setChannelType] = React.useState("web");
   const [channelType, setChannelType] = React.useState("web");
+  const [targetType, setTargetType] = React.useState<"agent" | "team">("agent");
+  const [agents, setAgents] = React.useState<Array<{ id: string; name: string }>>([]);
+  const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
+  const [targetId, setTargetId] = React.useState("");
+  const [agentConfigs, setAgentConfigs] = React.useState<AgentConfig[]>([]);
+  const [teamConfigs, setTeamConfigs] = React.useState<TeamConfig[]>([]);
+  const [targetConfigId, setTargetConfigId] = React.useState("");
   const [submitting, setSubmitting] = React.useState(false);
   const [submitting, setSubmitting] = React.useState(false);
 
 
   React.useEffect(() => {
   React.useEffect(() => {
     if (!appId && apps[0]) setAppId(apps[0].id);
     if (!appId && apps[0]) setAppId(apps[0].id);
   }, [appId, apps]);
   }, [appId, apps]);
 
 
+  React.useEffect(() => {
+    if (!open) return;
+    void Promise.all([listAgents(), listTeams()]).then(([nextAgents, nextTeams]) => {
+      setAgents(nextAgents.map((agent) => ({ id: agent.id, name: agent.name })));
+      setTeams(nextTeams);
+      if (!targetId) {
+        if (targetType === "agent" && nextAgents[0]) setTargetId(nextAgents[0].id);
+        if (targetType === "team" && nextTeams[0]) setTargetId(nextTeams[0].id);
+      }
+    });
+  }, [open, targetId, targetType]);
+
+  React.useEffect(() => {
+    if (!targetId) {
+      setAgentConfigs([]);
+      setTeamConfigs([]);
+      setTargetConfigId("");
+      return;
+    }
+    if (targetType === "agent") {
+      void listAgentConfigs(targetId).then((configs) => {
+        setAgentConfigs(configs);
+        setTeamConfigs([]);
+        setTargetConfigId((current) => current || configs[0]?.id || "");
+      });
+      return;
+    }
+    void listTeamConfigs(targetId).then((configs) => {
+      setTeamConfigs(configs);
+      setAgentConfigs([]);
+      setTargetConfigId((current) => current || configs[0]?.id || "");
+    });
+  }, [targetId, targetType]);
+
   async function submit(event: React.FormEvent) {
   async function submit(event: React.FormEvent) {
     event.preventDefault();
     event.preventDefault();
     setSubmitting(true);
     setSubmitting(true);
     try {
     try {
-      const session = await createSession({ user_id: userId, app_id: appId, title: title.trim(), channel_type: channelType });
+      const session = await createSession({
+        user_id: userId,
+        app_id: appId,
+        title: title.trim(),
+        channel_type: channelType,
+        runtime_target_type: targetType,
+        runtime_target_id: targetId,
+        runtime_target_config_id: targetConfigId || null,
+      });
       toast.success(t("sessions.sessionCreated"));
       toast.success(t("sessions.sessionCreated"));
       onCreated(session);
       onCreated(session);
       onOpenChange(false);
       onOpenChange(false);
@@ -72,13 +121,59 @@ export function CreateSessionDialog({
             ]}
             ]}
           />
           />
         </label>
         </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeTarget", "Runtime target")}</span>
+          <Select
+            value={targetType}
+            onChange={(event) => {
+              const nextType = event.target.value as "agent" | "team";
+              setTargetType(nextType);
+              setTargetId("");
+              setTargetConfigId("");
+            }}
+            options={[
+              { value: "agent", label: t("sessions.targetAgent", "Agent") },
+              { value: "team", label: t("sessions.targetTeam", "Team") },
+            ]}
+          />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeTargetItem", "Target")}</span>
+          <Select
+            value={targetId}
+            onChange={(event) => {
+              setTargetId(event.target.value);
+              setTargetConfigId("");
+            }}
+            options={targetType === "agent"
+              ? agents.map((agent) => ({ value: agent.id, label: agent.name }))
+              : teams.map((team) => ({ value: team.id, label: team.name }))}
+          />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeConfig", "Config")}</span>
+          <Select
+            value={targetConfigId}
+            onChange={(event) => setTargetConfigId(event.target.value)}
+            options={[
+              { value: "", label: t("sessions.runtimeConfigLatest", "Latest active config") },
+              ...(targetType === "agent"
+                ? agentConfigs.map((config) => ({ value: config.id, label: formatConfigLabel(config.id, config.role || "assistant") }))
+                : teamConfigs.map((config) => ({ value: config.id, label: formatConfigLabel(config.id, config.coordination_mode || "supervisor") }))),
+            ]}
+          />
+        </label>
         <div className="flex justify-end gap-2">
         <div className="flex justify-end gap-2">
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
             {t("common.cancel")}
             {t("common.cancel")}
           </Button>
           </Button>
-          <Button disabled={!appId || submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
+          <Button disabled={!appId || !targetId || submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
         </div>
         </div>
       </form>
       </form>
     </Dialog>
     </Dialog>
   );
   );
 }
 }
+
+function formatConfigLabel(id: string, secondary: string) {
+  return `${secondary} · ${id.slice(0, 8)}`;
+}

+ 4 - 3
web/src/pages/teams/components/CreateTeamDialog.tsx

@@ -30,7 +30,7 @@ const DEFAULT_POLICY: PolicyDraft = {
 };
 };
 
 
 function createDefaultMember(): MemberDraft {
 function createDefaultMember(): MemberDraft {
-  return { role: "executor", agent_id: "", responsibility: "" };
+  return { role: "specialist", agent_id: "", responsibility: "" };
 }
 }
 
 
 export function CreateTeamDialog({
 export function CreateTeamDialog({
@@ -211,9 +211,10 @@ function MemberEditor({ members, onChange, agents }: { members: MemberDraft[]; o
                     onChange={(event) => update(index, { role: event.target.value })}
                     onChange={(event) => update(index, { role: event.target.value })}
                     options={[
                     options={[
                       { value: "supervisor", label: t("teams.supervisor") },
                       { value: "supervisor", label: t("teams.supervisor") },
+                      { value: "planner", label: t("teams.planner") },
+                      { value: "specialist", label: t("teams.specialist") },
                       { value: "executor", label: t("teams.executor") },
                       { value: "executor", label: t("teams.executor") },
                       { value: "reviewer", label: t("teams.reviewer") },
                       { value: "reviewer", label: t("teams.reviewer") },
-                      { value: "planner", label: t("teams.planner") },
                     ]}
                     ]}
                   />
                   />
                 </Field>
                 </Field>
@@ -520,7 +521,7 @@ function buildTeamConfig(
 function readMemberDrafts(activeConfig?: TeamConfig): MemberDraft[] {
 function readMemberDrafts(activeConfig?: TeamConfig): MemberDraft[] {
   if (!activeConfig?.member_refs_json.length) return [createDefaultMember()];
   if (!activeConfig?.member_refs_json.length) return [createDefaultMember()];
   return activeConfig.member_refs_json.map((member) => ({
   return activeConfig.member_refs_json.map((member) => ({
-    role: readString(member, "role") ?? "executor",
+    role: readString(member, "role") ?? "specialist",
     agent_id: readString(member, "agent_id") ?? readString(member, "agentId") ?? "",
     agent_id: readString(member, "agent_id") ?? readString(member, "agentId") ?? "",
     responsibility: readString(member, "responsibility") ?? readString(member, "description") ?? "",
     responsibility: readString(member, "responsibility") ?? readString(member, "description") ?? "",
   }));
   }));

+ 2 - 2
web/src/pages/teams/components/TeamRuns.tsx

@@ -1000,9 +1000,9 @@ const TEAM_VALUE_LABEL_KEYS: Record<string, string> = {
   executor: "executor",
   executor: "executor",
   planner: "planner",
   planner: "planner",
   reviewer: "reviewer",
   reviewer: "reviewer",
-  specialist: "executor",
+  specialist: "specialist",
   supervisor: "supervisor",
   supervisor: "supervisor",
-  worker: "executor",
+  worker: "specialist",
 };
 };
 
 
 function wait(ms: number) {
 function wait(ms: number) {

+ 1 - 1
web/src/stores/ui.ts

@@ -15,7 +15,7 @@ export interface SiteSettings {
 }
 }
 
 
 const DEFAULT_SITE_SETTINGS: SiteSettings = {
 const DEFAULT_SITE_SETTINGS: SiteSettings = {
-  workspaceName: "Auto Platform",
+  workspaceName: "AgentDock",
   defaultRoute: "/dashboard",
   defaultRoute: "/dashboard",
   density: "comfortable",
   density: "comfortable",
   dashboardRefreshSeconds: 30,
   dashboardRefreshSeconds: 30,

+ 82 - 3
web/src/types/app.ts

@@ -1,13 +1,15 @@
 import type { JSONObject } from "./common";
 import type { JSONObject } from "./common";
 
 
-export interface AppCreateRequest {
+// ── Session-level app config (session-service) ─────────────────────────────
+
+export interface SessionAppCreateRequest {
   name: string;
   name: string;
   description?: string | null;
   description?: string | null;
   owner_user_id?: string | null;
   owner_user_id?: string | null;
   settings_json?: JSONObject;
   settings_json?: JSONObject;
 }
 }
 
 
-export interface AppResponse {
+export interface SessionAppResponse {
   id: string;
   id: string;
   name: string;
   name: string;
   description?: string | null;
   description?: string | null;
@@ -16,9 +18,86 @@ export interface AppResponse {
   created_time: string;
   created_time: string;
 }
 }
 
 
-export interface AppConfigResponse {
+export interface SessionAppConfigResponse {
   id: string;
   id: string;
   app_id: string;
   app_id: string;
   workflow_config_id: string;
   workflow_config_id: string;
   created_time: string;
   created_time: string;
 }
 }
+
+// ── Application definitions (api-gateway app module) ────────────────────────
+
+export type AppStatus = "draft" | "published" | "disabled";
+export type AppTargetType = "agent" | "team";
+
+export interface AppDefinition {
+  id: string;
+  code: string;
+  name: string;
+  description: string | null;
+  status: AppStatus;
+  target_type: AppTargetType;
+  target_id: string;
+  owner_user_id: string | null;
+  settings_json: string | null;
+  created_time: string;
+  updated_time: string;
+}
+
+export interface AppCreateRequest {
+  code: string;
+  name: string;
+  description?: string | null;
+  target_type: AppTargetType;
+  target_id: string;
+  owner_user_id?: string | null;
+  settings_json?: string | null;
+}
+
+export interface AppUpdateRequest {
+  app_id: string;
+  name?: string | null;
+  description?: string | null;
+  target_type?: AppTargetType | null;
+  target_id?: string | null;
+  settings_json?: string | null;
+}
+
+export interface AppStatusUpdateRequest {
+  app_id: string;
+  status: AppStatus;
+}
+
+export interface AppApiKeyResponse {
+  id: string;
+  app_id: string;
+  name: string;
+  key_prefix: string;
+  status: string;
+  scopes: string | null;
+  expires_time: string | null;
+  last_used_time: string | null;
+  created_time: string;
+}
+
+export interface AppApiKeyCreateResponse extends AppApiKeyResponse {
+  api_key: string;
+}
+
+export interface AppInvocationAuditResponse {
+  id: string;
+  app_id: string;
+  api_key_prefix: string | null;
+  request_id: string;
+  session_id: string | null;
+  run_request_id: string | null;
+  target_type: string;
+  target_id: string;
+  invoke_type: string;
+  status: string;
+  duration_ms: number;
+  error_code: string | null;
+  error_message: string | null;
+  client_metadata_json: string | null;
+  created_time: string;
+}

+ 18 - 0
web/src/types/session.ts

@@ -7,6 +7,9 @@ export interface Session {
   channel_type: string;
   channel_type: string;
   session_status: string;
   session_status: string;
   title?: string | null;
   title?: string | null;
+  runtime_target_type?: string | null;
+  runtime_target_id?: string | null;
+  runtime_target_config_id?: string | null;
   started_time?: string | null;
   started_time?: string | null;
   last_active_time?: string | null;
   last_active_time?: string | null;
   created_time: string;
   created_time: string;
@@ -33,3 +36,18 @@ export interface RunRequest {
   request_status: string;
   request_status: string;
   created_time: string;
   created_time: string;
 }
 }
+
+export interface SessionExecuteResult {
+  session_id: string;
+  run_request_id: string;
+  target_type: string;
+  target_id: string;
+  target_config_id?: string | null;
+  request_status: string;
+  user_message_id: string;
+  assistant_message_id?: string | null;
+  agent_run_id?: string | null;
+  team_run_id?: string | null;
+  output_text?: string | null;
+  error_message?: string | null;
+}