diff --git a/.github/workflows/build-gateway.yml b/.github/workflows/build-gateway.yml
index b6e7730ff..516e68c3c 100644
--- a/.github/workflows/build-gateway.yml
+++ b/.github/workflows/build-gateway.yml
@@ -115,6 +115,7 @@ jobs:
cd /tmp/digests
docker buildx imagetools create \
-t ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }} \
+ -t ${{ env.IMAGE_NAME }}:v${{ steps.tag.outputs.version }} \
-t ${{ env.IMAGE_NAME }}:latest \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
diff --git a/.github/workflows/build-operator-branch.yml b/.github/workflows/build-operator-branch.yml
new file mode 100644
index 000000000..efdaa535e
--- /dev/null
+++ b/.github/workflows/build-operator-branch.yml
@@ -0,0 +1,36 @@
+name: Build Operator (Branch)
+
+on:
+ push:
+ branches:
+ - "operator/**"
+ workflow_dispatch:
+
+jobs:
+ build-macos:
+ runs-on: [self-hosted, macOS, ARM64]
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Rust
+ run: |
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+ echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ - name: Build (aarch64-apple-darwin)
+ run: cargo build --release
+
+ - name: Package
+ run: |
+ mkdir -p dist
+ cp target/release/openab dist/
+ cd dist && tar czf ../openab-macos-arm64.tar.gz *
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: openab-macos-arm64
+ path: openab-macos-arm64.tar.gz
+ retention-days: 7
diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml
new file mode 100644
index 000000000..195891c37
--- /dev/null
+++ b/.github/workflows/build-operator.yml
@@ -0,0 +1,285 @@
+name: Build Operator
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Version tag (e.g. v0.7.0-beta.1 or v0.7.0)'
+ required: true
+ type: string
+ default: 'v'
+ dry_run:
+ description: 'Dry run (build only, no push)'
+ required: false
+ type: boolean
+ default: false
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ resolve-tag:
+ runs-on: ubuntu-latest
+ outputs:
+ tag: ${{ steps.resolve.outputs.tag }}
+ chart_version: ${{ steps.resolve.outputs.chart_version }}
+ is_prerelease: ${{ steps.resolve.outputs.is_prerelease }}
+ steps:
+ - name: Resolve and validate tag
+ id: resolve
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ TAG="${{ inputs.tag }}"
+ else
+ TAG="${GITHUB_REF_NAME}"
+ fi
+
+ # Validate tag format
+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then
+ echo "::error::Invalid tag format '${TAG}'. Expected v{major}.{minor}.{patch}[-prerelease]"
+ exit 1
+ fi
+
+ CHART_VERSION="${TAG#v}"
+
+ # Pre-release if version contains '-' (e.g. 0.7.0-beta.1)
+ if [[ "$CHART_VERSION" == *-* ]]; then
+ IS_PRERELEASE="true"
+ else
+ IS_PRERELEASE="false"
+ fi
+
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ echo "chart_version=${CHART_VERSION}" >> "$GITHUB_OUTPUT"
+ echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
+
+ # ── Pre-release path: full build ──────────────────────────────
+
+ build-image:
+ needs: resolve-tag
+ if: ${{ needs.resolve-tag.outputs.is_prerelease == 'true' }}
+ strategy:
+ matrix:
+ variant:
+ - { suffix: "", dockerfile: "Dockerfile", artifact: "default" }
+ - { suffix: "-codex", dockerfile: "Dockerfile.codex", artifact: "codex" }
+ - { suffix: "-claude", dockerfile: "Dockerfile.claude", artifact: "claude" }
+ - { suffix: "-gemini", dockerfile: "Dockerfile.gemini", artifact: "gemini" }
+ - { suffix: "-copilot", dockerfile: "Dockerfile.copilot", artifact: "copilot" }
+ - { suffix: "-opencode", dockerfile: "Dockerfile.opencode", artifact: "opencode" }
+ - { suffix: "-cursor", dockerfile: "Dockerfile.cursor", artifact: "cursor" }
+ - { suffix: "-hermes", dockerfile: "Dockerfile.hermes", artifact: "hermes" }
+ - { suffix: "-grok", dockerfile: "Dockerfile.grok", artifact: "grok" }
+ - { suffix: "-antigravity", dockerfile: "Dockerfile.antigravity", artifact: "antigravity" }
+ - { suffix: "-pi", dockerfile: "Dockerfile.pi", artifact: "pi" }
+ - { suffix: "-native", dockerfile: "Dockerfile.native", artifact: "native" }
+ platform:
+ - { os: linux/amd64, runner: ubuntu-latest }
+ - { os: linux/arm64, runner: ubuntu-24.04-arm }
+ runs-on: ${{ matrix.platform.runner }}
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: docker/setup-buildx-action@v3
+
+ - uses: docker/login-action@v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker metadata
+ id: meta
+ uses: docker/metadata-action@v6
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}
+
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ${{ matrix.variant.dockerfile }}
+ platforms: ${{ matrix.platform.os }}
+ outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }},push-by-digest=true,name-canonical=true,push=${{ inputs.dry_run != true }}
+ cache-from: type=gha,scope=${{ matrix.variant.suffix }}-${{ matrix.platform.os }}
+ cache-to: type=gha,scope=${{ matrix.variant.suffix }}-${{ matrix.platform.os }},mode=max
+
+ - name: Export digest
+ if: inputs.dry_run != true
+ run: |
+ mkdir -p /tmp/digests
+ digest="${{ steps.build.outputs.digest }}"
+ touch "/tmp/digests/${digest#sha256:}"
+
+ - name: Upload digest
+ if: inputs.dry_run != true
+ uses: actions/upload-artifact@v4
+ with:
+ name: digests-${{ matrix.variant.artifact }}-${{ matrix.platform.runner }}
+ path: /tmp/digests/*
+ retention-days: 1
+
+ merge-manifests:
+ needs: [resolve-tag, build-image]
+ if: ${{ inputs.dry_run != true && needs.resolve-tag.outputs.is_prerelease == 'true' }}
+ strategy:
+ matrix:
+ variant:
+ - { suffix: "", artifact: "default" }
+ - { suffix: "-codex", artifact: "codex" }
+ - { suffix: "-claude", artifact: "claude" }
+ - { suffix: "-gemini", artifact: "gemini" }
+ - { suffix: "-copilot", artifact: "copilot" }
+ - { suffix: "-opencode", artifact: "opencode" }
+ - { suffix: "-cursor", artifact: "cursor" }
+ - { suffix: "-hermes", artifact: "hermes" }
+ - { suffix: "-grok", artifact: "grok" }
+ - { suffix: "-antigravity", artifact: "antigravity" }
+ - { suffix: "-pi", artifact: "pi" }
+ - { suffix: "-native", artifact: "native" }
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Download digests
+ uses: actions/download-artifact@v4
+ with:
+ path: /tmp/digests
+ pattern: digests-${{ matrix.variant.artifact }}-*
+ merge-multiple: true
+
+ - uses: docker/setup-buildx-action@v3
+
+ - uses: docker/login-action@v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker metadata
+ id: meta
+ uses: docker/metadata-action@v6
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}
+ tags: |
+ type=sha,prefix=
+ type=semver,pattern={{version}},value=${{ needs.resolve-tag.outputs.tag }}
+ type=raw,value=beta
+
+ - name: Create manifest list
+ working-directory: /tmp/digests
+ run: |
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}@sha256:%s ' *)
+
+ # ── Stable path: promote pre-release image (no rebuild) ──────
+
+ promote-stable:
+ needs: resolve-tag
+ if: ${{ inputs.dry_run != true && needs.resolve-tag.outputs.is_prerelease == 'false' }}
+ strategy:
+ matrix:
+ variant:
+ - { suffix: "" }
+ - { suffix: "-codex" }
+ - { suffix: "-claude" }
+ - { suffix: "-gemini" }
+ - { suffix: "-copilot" }
+ - { suffix: "-opencode" }
+ - { suffix: "-cursor" }
+ - { suffix: "-hermes" }
+ - { suffix: "-grok" }
+ - { suffix: "-antigravity" }
+ - { suffix: "-pi" }
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - uses: docker/setup-buildx-action@v3
+
+ - uses: docker/login-action@v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Find pre-release image
+ id: find-prerelease
+ run: |
+ CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}"
+ # Find latest pre-release tag matching this version (e.g. v0.7.0-beta.1)
+ PRERELEASE_TAG=$(git tag -l "v${CHART_VERSION}-*" --sort=-v:refname | head -1)
+ if [ -z "$PRERELEASE_TAG" ]; then
+ echo "::error::No pre-release tag found for v${CHART_VERSION}-*. Run a pre-release build first."
+ exit 1
+ fi
+ PRERELEASE_VERSION="${PRERELEASE_TAG#v}"
+ echo "Found pre-release: ${PRERELEASE_TAG} (${PRERELEASE_VERSION})"
+ echo "prerelease_version=${PRERELEASE_VERSION}" >> "$GITHUB_OUTPUT"
+
+ - name: Verify pre-release image exists
+ run: |
+ IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}"
+ PRERELEASE_VERSION="${{ steps.find-prerelease.outputs.prerelease_version }}"
+ echo "Checking ${IMAGE}:${PRERELEASE_VERSION} ..."
+ docker buildx imagetools inspect "${IMAGE}:${PRERELEASE_VERSION}" || \
+ { echo "::error::Image ${IMAGE}:${PRERELEASE_VERSION} not found — build the pre-release first"; exit 1; }
+
+ - name: Promote to stable tags
+ run: |
+ IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.variant.suffix }}"
+ PRERELEASE_VERSION="${{ steps.find-prerelease.outputs.prerelease_version }}"
+ CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}"
+ MAJOR_MINOR="${CHART_VERSION%.*}"
+
+ echo "Promoting ${IMAGE}:${PRERELEASE_VERSION} → ${CHART_VERSION}, ${MAJOR_MINOR}, latest, stable"
+ docker buildx imagetools create \
+ -t "${IMAGE}:${CHART_VERSION}" \
+ -t "${IMAGE}:${MAJOR_MINOR}" \
+ -t "${IMAGE}:latest" \
+ -t "${IMAGE}:stable" \
+ "${IMAGE}:${PRERELEASE_VERSION}"
+
+ # ── Chart release (runs after either path) ───────────────────
+
+ release-chart:
+ needs: [resolve-tag, merge-manifests, promote-stable]
+ if: >-
+ ${{ always() && inputs.dry_run != true &&
+ needs.resolve-tag.result == 'success' &&
+ (needs.merge-manifests.result == 'success' || needs.promote-stable.result == 'success') }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Helm
+ uses: azure/setup-helm@v4
+
+ - uses: docker/login-action@v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push chart to OCI
+ run: |
+ CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}"
+ helm package charts/openab
+ helm push openab-${CHART_VERSION}.tgz oci://ghcr.io/${{ github.repository_owner }}/charts
diff --git a/.github/workflows/ci-auth-proxy.yml b/.github/workflows/ci-auth-proxy.yml
new file mode 100644
index 000000000..740b647c5
--- /dev/null
+++ b/.github/workflows/ci-auth-proxy.yml
@@ -0,0 +1,42 @@
+name: CI (openab-auth-proxy)
+
+on:
+ pull_request:
+ paths:
+ - "openab-auth-proxy/**"
+ push:
+ branches: [main]
+ paths:
+ - "openab-auth-proxy/**"
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: openab-auth-proxy
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: openab-auth-proxy
+
+ - name: cargo check
+ run: cargo check
+
+ - name: cargo clippy
+ run: cargo clippy -- -D warnings
+
+ - name: cargo test
+ run: cargo test
+
+ - name: cargo build (release)
+ run: cargo build --release
diff --git a/.github/workflows/ci-openab-agent.yml b/.github/workflows/ci-openab-agent.yml
new file mode 100644
index 000000000..c0d5a3727
--- /dev/null
+++ b/.github/workflows/ci-openab-agent.yml
@@ -0,0 +1,39 @@
+name: CI openab-agent
+
+on:
+ push:
+ paths:
+ - 'openab-agent/**'
+ pull_request:
+ paths:
+ - 'openab-agent/**'
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: openab-agent
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: openab-agent
+ - run: cargo fmt --check
+ - run: cargo clippy -- -D warnings
+ - run: cargo test
+ - run: cargo test -- --ignored
+ env:
+ ANTHROPIC_API_KEY: "fake-key-for-ci"
+ - name: ACP smoke test
+ run: |
+ cargo build --release
+ # Test: initialize returns valid ACP response
+ RESP=$(echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | timeout 5 ./target/release/openab-agent 2>/dev/null | head -1)
+ echo "Response: $RESP"
+ echo "$RESP" | grep -q '"agentInfo"' || (echo "FAIL: no agentInfo in response" && exit 1)
+ echo "$RESP" | grep -q '"openab-agent"' || (echo "FAIL: wrong agent name" && exit 1)
+ echo "✅ ACP initialize OK"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4239edd95..4a2e000e3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,8 @@ on:
pull_request:
paths:
- "src/**"
+ - "gateway/**"
+ - "operator/**"
- "Cargo.toml"
- "Cargo.lock"
- "Dockerfile*"
@@ -12,22 +14,84 @@ env:
CARGO_TERM_COLOR: always
jobs:
- check:
+ changes:
runs-on: ubuntu-latest
+ outputs:
+ core: ${{ steps.filter.outputs.core }}
+ gateway: ${{ steps.filter.outputs.gateway }}
+ operator: ${{ steps.filter.outputs.operator }}
steps:
- uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - id: filter
+ run: |
+ BASE=${{ github.event.pull_request.base.sha }}
+ HEAD=${{ github.event.pull_request.head.sha }}
+ CHANGED=$(git diff --name-only "$BASE" "$HEAD")
+ echo "core=$(echo "$CHANGED" | grep -qE '^(src/|Cargo\.(toml|lock))' && echo true || echo false)" >> "$GITHUB_OUTPUT"
+ echo "gateway=$(echo "$CHANGED" | grep -q '^gateway/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
+ echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
+ check:
+ needs: changes
+ if: needs.changes.outputs.core == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
-
- uses: Swatinem/rust-cache@v2
-
- name: cargo check
run: cargo check
+ - name: cargo clippy
+ run: cargo clippy -- -D warnings
+ - name: cargo test
+ run: cargo test
+ gateway:
+ needs: changes
+ if: needs.changes.outputs.gateway == 'true'
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: gateway
+ steps:
+ - uses: actions/checkout@v6
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: gateway
+ - name: cargo check
+ run: cargo check
- name: cargo clippy
run: cargo clippy -- -D warnings
+ - name: cargo test
+ run: cargo test
+ operator:
+ needs: changes
+ if: needs.changes.outputs.operator == 'true'
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: operator
+ steps:
+ - uses: actions/checkout@v6
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: operator
+ - name: cargo check
+ run: cargo check
+ - name: cargo clippy
+ run: cargo clippy -- -D warnings
- name: cargo test
run: cargo test
+ - name: cargo build
+ run: cargo build --release
diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml
index bf10ca989..77170fd4f 100644
--- a/.github/workflows/close-stale-prs.yml
+++ b/.github/workflows/close-stale-prs.yml
@@ -47,7 +47,7 @@ jobs:
await github.rest.issues.createComment({
...context.repo,
issue_number: pr.number,
- body: `🔒 Auto-closing: this PR has had the \`${label}\` label for more than ${staleDays} days without a Discord Discussion URL being added.\n\nFeel free to reopen after adding the discussion link to the PR body.`
+ body: `🔒 Auto-closing: this PR has had the \`${label}\` label for more than ${staleDays} days without activity from the author.\n\nIf you'd like to continue working on this, feel free to reopen and leave a comment.`
});
await github.rest.pulls.update({
diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml
index 64b4653a3..da5cc2980 100644
--- a/.github/workflows/docker-smoke-test.yml
+++ b/.github/workflows/docker-smoke-test.yml
@@ -20,6 +20,10 @@ jobs:
- { dockerfile: Dockerfile.copilot, suffix: "-copilot", agent: "copilot", agent_args: "--acp" }
- { dockerfile: Dockerfile.opencode, suffix: "-opencode", agent: "opencode", agent_args: "acp" }
- { dockerfile: Dockerfile.cursor, suffix: "-cursor", agent: "cursor-agent", agent_args: "acp" }
+ - { dockerfile: Dockerfile.hermes, suffix: "-hermes", agent: "hermes-acp", agent_args: "" }
+ - { dockerfile: Dockerfile.grok, suffix: "-grok", agent: "grok", agent_args: "agent stdio" }
+ - { dockerfile: Dockerfile.antigravity, suffix: "-antigravity", agent: "agy-acp", agent_args: "" }
+ - { dockerfile: Dockerfile.pi, suffix: "-pi", agent: "pi-acp", agent_args: "" }
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -41,17 +45,16 @@ jobs:
- name: Verify agent responds
run: |
- INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}'
+ INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}'
- RAW=$(echo "$INIT" | timeout 30 docker run --rm -i \
- --entrypoint ${{ matrix.variant.agent }} \
- openab-test${{ matrix.variant.suffix }} \
- ${{ matrix.variant.agent_args }} 2>/dev/null || true)
+ # Start agent in background, send init, capture output with timeout
+ CID=$(docker run -d -i --entrypoint sh openab-test${{ matrix.variant.suffix }} -c 'exec ${{ matrix.variant.agent }} ${{ matrix.variant.agent_args }} 2>/dev/null')
+ echo "$INIT" | docker attach --no-stdin=false "$CID" &
+ sleep 5
+ RESPONSE=$(docker logs "$CID" 2>/dev/null | grep -m1 '^{' || true)
+ docker rm -f "$CID" >/dev/null 2>&1
- echo "Raw output:"
- echo "$RAW"
-
- RESPONSE=$(echo "$RAW" | grep -m1 '^{' || true)
+ echo "Response: $RESPONSE"
if [ -n "$RESPONSE" ] && echo "$RESPONSE" | jq -e '.result.agentInfo.name' > /dev/null 2>&1; then
AGENT_NAME=$(echo "$RESPONSE" | jq -r '.result.agentInfo.name')
diff --git a/.github/workflows/pending-maintainer.yml b/.github/workflows/pending-maintainer.yml
index 55c741d86..c153e1113 100644
--- a/.github/workflows/pending-maintainer.yml
+++ b/.github/workflows/pending-maintainer.yml
@@ -53,12 +53,6 @@ jobs:
continue;
}
- // Skip if closing-soon — contributor has incomplete work
- if (labels.includes('closing-soon')) {
- console.log(`#${prNumber} — closing-soon, skipping`);
- continue;
- }
-
// Skip if has merge conflicts or already labeled needs-rebase
if (pr.mergeable === false || labels.includes('needs-rebase')) {
console.log(`#${prNumber} — has conflicts or needs-rebase, skipping`);
@@ -121,5 +115,12 @@ jobs:
name: CONTRIBUTOR
}).catch(() => {});
}
+ if (labels.includes('closing-soon')) {
+ await github.rest.issues.removeLabel({
+ ...context.repo,
+ issue_number: prNumber,
+ name: 'closing-soon'
+ }).catch(() => {});
+ }
console.log(`#${prNumber} — all clear, set ${MAINTAINER}`);
}
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
index 69ba89077..2dbf2a69c 100644
--- a/.github/workflows/pr-preview.yml
+++ b/.github/workflows/pr-preview.yml
@@ -13,11 +13,13 @@ on:
type: choice
options:
- default
+ - antigravity
- claude
- codex
- copilot
- cursor
- gemini
+ - hermes
- opencode
default: 'default'
diff --git a/.gitignore b/.gitignore
index 26834d0d4..fe7eedff9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,12 @@
/target
+gateway/target/
config.toml
*.swp
.DS_Store
.env
.kiro/
+
+# Claude Code (https://claude.ai/code)
CLAUDE.md
-gateway/target/
+.claude/
+.claude.local.md
diff --git a/AGENTS.md b/AGENTS.md
index 666c44162..b61901c68 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,7 +75,7 @@ Never leak `DISCORD_BOT_TOKEN` or other OAB credentials to the agent.
### 4. Dockerfile Discipline
-There are 7 Dockerfiles: `Dockerfile`, `Dockerfile.claude`, `Dockerfile.codex`, `Dockerfile.copilot`, `Dockerfile.cursor`, `Dockerfile.gemini`, `Dockerfile.opencode`.
+There are 11 Dockerfiles: `Dockerfile`, `Dockerfile.antigravity`, `Dockerfile.claude`, `Dockerfile.codex`, `Dockerfile.copilot`, `Dockerfile.cursor`, `Dockerfile.gemini`, `Dockerfile.grok`, `Dockerfile.hermes`, `Dockerfile.opencode`, `Dockerfile.pi`.
A change to one MUST be evaluated against ALL. Common layers (base image, openab binary, tini) are shared — update all or explain why not.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 08abe5ab9..556fa27e0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -80,3 +80,74 @@ cargo check
- Run `cargo fmt` before committing
- Run `cargo clippy` and address warnings
- Keep PRs focused — one feature or fix per PR
+
+## PR Lifecycle
+
+Every PR follows a label-driven lifecycle that keeps the review loop moving.
+
+```
+┌──────────────┐
+│ PR Created │
+└──────┬───────┘
+ │
+ ▼
+┌──────────────────────┐
+│ Automated Checks │
+│ (CI, rebase, etc.) │
+└──────┬───────────────┘
+ │
+ ├── all pass ──────────────────────►┌──────────────────────┐
+ │ │ pending-maintainer │
+ │ └──────────┬───────────┘
+ │ │
+ │ ├── LGTM → approve & merge (or request
+ │ │ another maintainer review)
+ │ │ stays pending-maintainer
+ │ │
+ │ └── pending actions for contributor
+ │ │
+ │ ▼
+ └── any fail ──────────────────────►┌──────────────────────┐
+ │ pending-contributor │◄─────────┐
+ └──────────┬───────────┘ │
+ │ │
+ │ stale 2 days │
+ │ (no author activity) │
+ ▼ │
+ ┌───────────────────┐ │
+ │ closing-soon │ │
+ │ (or immediate if │ │
+ │ blocker detected)│ │
+ └────────┬──────────┘ │
+ │ │
+ ┌────────────┴──────────┐ │
+ │ │ │
+ ▼ ▼ │
+ author comments 3 more days │
+ within 3 days no activity │
+ │ │ │
+ ▼ ▼ │
+ ┌────────────────────┐ ┌────────────┐ │
+ │ pending-maintainer │ │ PR Closed │ │
+ │ (labels removed) │ └────────────┘ │
+ └────────┬───────────┘ │
+ │ │
+ └── re-check fails ────────────────────┘
+```
+
+### Label Transitions
+
+| Current State | Trigger | Action |
+|---------------|---------|--------|
+| `pending-contributor` | No author activity for 2 days | Add `closing-soon` |
+| `closing-soon` | No author activity for 3 more days | Auto-close PR |
+| `pending-contributor` | Author adds a comment | Remove `pending-contributor`, add `pending-maintainer` |
+| `closing-soon` | Author adds a comment | Remove `closing-soon` and `pending-contributor`, add `pending-maintainer` |
+
+### Key Rules
+
+- **`pending-contributor`** — the ball is on the contributor; maintainers are waiting for updates.
+- **`closing-soon`** — warning that the PR will be auto-closed if no response within 3 days.
+- **Author comment always resets** — any comment by the PR author removes `pending-contributor` and `closing-soon`, flipping the PR back to `pending-maintainer`.
+- **Re-check may re-apply `closing-soon`** — after the flip, automated checks still run. If blockers remain (e.g., missing Discord URL, CI failure, `needs-rebase`), `closing-soon` will be re-applied immediately, keeping the ball on the contributor.
+- **Immediate `closing-soon`** — in some cases (e.g., missing Discord Discussion URL), `closing-soon` is applied immediately without waiting for the stale period.
diff --git a/Cargo.lock b/Cargo.lock
index 6950fc242..ad9a99c7f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -196,6 +196,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
+ "serde",
"wasm-bindgen",
"windows-link",
]
@@ -1070,6 +1071,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"toml",
+ "toml_edit",
"tracing",
"tracing-subscriber",
"unicode-width",
diff --git a/Cargo.toml b/Cargo.toml
index da2a7bb4a..9b64a3735 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "openab"
-version = "0.8.3"
+version = "0.8.4"
edition = "2021"
license = "MIT"
@@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
+toml_edit = "0.22"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend", "cache"] }
@@ -27,7 +28,7 @@ unicode-width = "0.2"
pulldown-cmark = { version = "0.13", default-features = false }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
cron = "0.16.0"
-chrono = "0.4.44"
+chrono = { version = "0.4.44", features = ["serde"] }
chrono-tz = "0.10.4"
[target.'cfg(unix)'.dependencies]
diff --git a/Dockerfile.antigravity b/Dockerfile.antigravity
new file mode 100644
index 000000000..a73fd1f13
--- /dev/null
+++ b/Dockerfile.antigravity
@@ -0,0 +1,55 @@
+# --- Build openab ---
+FROM rust:1-bookworm AS builder
+WORKDIR /build
+COPY Cargo.toml Cargo.lock ./
+RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src
+COPY src/ src/
+RUN touch src/main.rs && cargo build --release
+
+# --- Build agy-acp adapter ---
+FROM rust:1-bookworm AS adapter-builder
+WORKDIR /build
+COPY agy-acp/Cargo.toml ./
+RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src
+COPY agy-acp/src/ src/
+RUN touch src/main.rs && cargo build --release
+
+# --- Runtime stage ---
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps tini && rm -rf /var/lib/apt/lists/*
+
+# Install agy (Google Antigravity CLI)
+RUN ARCH=$(dpkg --print-architecture) && \
+ case "$ARCH" in \
+ amd64) PLATFORM="linux_amd64" ;; \
+ arm64) PLATFORM="linux_arm64" ;; \
+ *) echo "unsupported arch: $ARCH" && exit 1 ;; \
+ esac && \
+ MANIFEST_URL="https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/${PLATFORM}.json" && \
+ DOWNLOAD_URL=$(curl -fsSL "$MANIFEST_URL" | grep -o '"url": *"[^"]*"' | cut -d'"' -f4) && \
+ curl -fsSL "$DOWNLOAD_URL" | tar -xz -C /usr/local/bin && \
+ mv /usr/local/bin/antigravity /usr/local/bin/agy && \
+ chmod +x /usr/local/bin/agy
+
+# Install gh CLI
+RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
+ -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
+ > /etc/apt/sources.list.d/github-cli.list && \
+ apt-get update && apt-get install -y --no-install-recommends gh && \
+ rm -rf /var/lib/apt/lists/*
+
+RUN useradd -m -s /bin/bash agent
+ENV HOME=/home/agent
+WORKDIR /home/agent
+
+COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab
+COPY --from=adapter-builder --chown=agent:agent /build/target/release/agy-acp /usr/local/bin/agy-acp
+
+RUN mkdir -p /home/agent/.gemini && chown -R agent:agent /home/agent/.gemini
+
+USER agent
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD pgrep -x openab || exit 1
+ENTRYPOINT ["tini", "--"]
+CMD ["openab", "run", "-c", "/etc/openab/config.toml"]
diff --git a/Dockerfile.claude b/Dockerfile.claude
index e98faf577..03ed1c3e3 100644
--- a/Dockerfile.claude
+++ b/Dockerfile.claude
@@ -8,13 +8,13 @@ RUN touch src/main.rs && cargo build --release
# --- Runtime stage ---
FROM node:22-bookworm-slim
-RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap socat && rm -rf /var/lib/apt/lists/*
# Install claude-agent-acp adapter and Claude Code CLI.
# Without CLAUDE_CODE_EXECUTABLE the adapter uses its own bundled SDK cli.js,
# ignoring the globally installed claude-code binary (see #418).
ARG CLAUDE_AGENT_ACP_VERSION=0.29.2
-ARG CLAUDE_CODE_VERSION=2.1.124
+ARG CLAUDE_CODE_VERSION=2.1.146
RUN npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION} @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} --retry 3
ENV CLAUDE_CODE_EXECUTABLE=/usr/local/bin/claude
@@ -31,6 +31,8 @@ WORKDIR /home/node
COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab
+RUN mkdir -p /home/node/.claude && chown -R node:node /home/node/.claude
+
USER node
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD pgrep -x openab || exit 1
diff --git a/Dockerfile.codex b/Dockerfile.codex
index bdc984f04..32fc6a257 100644
--- a/Dockerfile.codex
+++ b/Dockerfile.codex
@@ -8,11 +8,11 @@ RUN touch src/main.rs && cargo build --release
# --- Runtime stage ---
FROM node:22-bookworm-slim
-RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap && rm -rf /var/lib/apt/lists/*
# Pre-install codex-acp and codex CLI globally
-ARG CODEX_ACP_VERSION=0.10.0
-ARG CODEX_VERSION=0.128.0
+ARG CODEX_ACP_VERSION=0.14.0
+ARG CODEX_VERSION=0.133.0
RUN npm install -g @zed-industries/codex-acp@${CODEX_ACP_VERSION} @openai/codex@${CODEX_VERSION} --retry 3
# Install gh CLI
@@ -28,6 +28,8 @@ WORKDIR /home/node
COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab
+RUN mkdir -p /home/node/.codex && chown -R node:node /home/node/.codex
+
USER node
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD pgrep -x openab || exit 1
diff --git a/Dockerfile.copilot b/Dockerfile.copilot
index 2391f3d9f..99b81696d 100644
--- a/Dockerfile.copilot
+++ b/Dockerfile.copilot
@@ -11,7 +11,7 @@ FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/*
# Install GitHub Copilot CLI via npm (pinned version)
-ARG COPILOT_VERSION=1.0.40
+ARG COPILOT_VERSION=1.0.51
RUN npm install -g @github/copilot@${COPILOT_VERSION} --retry 3
# Install gh CLI (for auth and token management)
@@ -27,6 +27,8 @@ WORKDIR /home/node
COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab
+RUN mkdir -p /home/node/.copilot && chown -R node:node /home/node/.copilot
+
USER node
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD pgrep -x openab || exit 1
diff --git a/Dockerfile.cursor b/Dockerfile.cursor
index d703da7be..eb37d2600 100644
--- a/Dockerfile.cursor
+++ b/Dockerfile.cursor
@@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
# URL scheme scraped from Cursor's official downloads page — no apt/yum package exists.
# If Cursor changes this pattern, the build fails with curl 404. Monitor
# https://cursor.com/cli or https://docs.cursor.com/cli for version/URL updates.
-ARG CURSOR_VERSION=2026.04.30-4edb302
+ARG CURSOR_VERSION=2026.05.20-2b5dd59
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "arm64" ]; then ARCH=arm64; else ARCH=x64; fi && \
curl -fSL "https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${ARCH}/agent-cli-package.tar.gz" \
@@ -39,6 +39,8 @@ WORKDIR /home/agent
COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab
+RUN mkdir -p /home/agent/.cursor && chown -R agent:agent /home/agent/.cursor
+
USER agent
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD pgrep -x openab || exit 1
diff --git a/Dockerfile.gemini b/Dockerfile.gemini
index 506dac690..5fdd3c378 100644
--- a/Dockerfile.gemini
+++ b/Dockerfile.gemini
@@ -11,7 +11,7 @@ FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/*
# Install Gemini CLI (native ACP support via --acp)
-ARG GEMINI_CLI_VERSION=0.40.1
+ARG GEMINI_CLI_VERSION=0.42.0
RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} --retry 3
# Install gh CLI
diff --git a/Dockerfile.grok b/Dockerfile.grok
new file mode 100644
index 000000000..27b997ffe
--- /dev/null
+++ b/Dockerfile.grok
@@ -0,0 +1,58 @@
+# --- Build stage ---
+FROM rust:1-bookworm AS builder
+WORKDIR /build
+COPY Cargo.toml Cargo.lock ./
+RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src
+COPY src/ src/
+RUN touch src/main.rs && cargo build --release
+
+# --- Runtime stage ---
+FROM debian:bookworm-slim
+
+# Create agent user first so WORKDIR gets correct ownership
+RUN useradd -m -u 1000 agent
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates curl procps ripgrep tini git && \
+ rm -rf /var/lib/apt/lists/*
+
+# Install Grok Build CLI — pinned version with SHA256 checksum verification.
+# Binary sourced from xAI's public artifacts bucket (same source the official
+# `https://x.ai/cli/install.sh` resolves to) so the build is reproducible.
+ARG GROK_VERSION=0.1.211
+ARG GROK_SHA256_AMD64=9245f9c921b1f91bfb34ee2ee27715000b65e947723541ff1a612eaece468bd0
+ARG GROK_SHA256_ARM64=b283cb72fdc3143365e044fd7f8630e14845640d4d81404bb36905cc7209abc6
+ARG TARGETPLATFORM
+RUN set -eux; \
+ case "${TARGETPLATFORM:-linux/amd64}" in \
+ "linux/amd64") arch=x86_64; sha="${GROK_SHA256_AMD64}" ;; \
+ "linux/arm64") arch=aarch64; sha="${GROK_SHA256_ARM64}" ;; \
+ *) echo "Unsupported platform: ${TARGETPLATFORM}" >&2; exit 1 ;; \
+ esac; \
+ curl -fsSL "https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-${GROK_VERSION}-linux-${arch}" \
+ -o /tmp/grok && \
+ echo "${sha} /tmp/grok" | sha256sum -c - && \
+ install -m 0755 /tmp/grok /usr/local/bin/grok && \
+ rm /tmp/grok
+
+# Install gh CLI
+RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
+ -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
+ > /etc/apt/sources.list.d/github-cli.list && \
+ apt-get update && apt-get install -y --no-install-recommends gh && \
+ rm -rf /var/lib/apt/lists/*
+
+ENV HOME=/home/agent
+WORKDIR /home/agent
+
+COPY --from=builder --chown=1000:1000 /build/target/release/openab /usr/local/bin/openab
+
+# Pre-create credential dir so a PVC mounted at ~/.grok inherits correct ownership
+RUN mkdir -p /home/agent/.grok && chown -R agent:agent /home/agent
+
+USER agent
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD pgrep -x openab || exit 1
+ENTRYPOINT ["tini", "--"]
+CMD ["openab", "run", "-c", "/etc/openab/config.toml"]
diff --git a/Dockerfile.hermes b/Dockerfile.hermes
new file mode 100644
index 000000000..7de02b411
--- /dev/null
+++ b/Dockerfile.hermes
@@ -0,0 +1,52 @@
+# --- Build stage ---
+FROM rust:1-bookworm AS builder
+WORKDIR /build
+COPY Cargo.toml Cargo.lock ./
+RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src
+COPY src/ src/
+RUN touch src/main.rs && cargo build --release
+
+# --- Runtime stage ---
+FROM python:3.12-slim-bookworm
+
+# Create agent user first so WORKDIR gets correct ownership
+RUN useradd -m -u 1000 agent
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates curl procps ripgrep tini git ffmpeg xz-utils && \
+ rm -rf /var/lib/apt/lists/*
+
+# Install Hermes Agent — pinned to known commit with checksum verification
+# Root install uses FHS layout: binary at /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent
+# HERMES_HOME points to agent user's data dir for OAuth tokens and config
+ARG HERMES_INSTALL_COMMIT=cc07e30f45267c00fac97ea5569c606aca5a1ffb
+ARG HERMES_INSTALL_SHA256=cb94b83b96cc924716bd1651411955da7495912ef68affe6788840e6cf147d41
+RUN curl -fsSL "https://raw.githubusercontent.com/NousResearch/hermes-agent/${HERMES_INSTALL_COMMIT}/scripts/install.sh" \
+ -o /tmp/install-hermes.sh && \
+ echo "${HERMES_INSTALL_SHA256} /tmp/install-hermes.sh" | sha256sum -c - && \
+ HERMES_HOME=/home/agent/.hermes bash /tmp/install-hermes.sh && \
+ rm /tmp/install-hermes.sh && \
+ chmod -R a+rX /root/.local/share/uv && \
+ chmod a+rx /root /root/.local /root/.local/share && \
+ ln -sf /usr/local/lib/hermes-agent/venv/bin/hermes-acp /usr/local/bin/hermes-acp
+
+# Install gh CLI
+RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
+ -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
+ > /etc/apt/sources.list.d/github-cli.list && \
+ apt-get update && apt-get install -y --no-install-recommends gh && \
+ rm -rf /var/lib/apt/lists/*
+
+ENV HOME=/home/agent
+WORKDIR /home/agent
+
+COPY --from=builder --chown=1000:1000 /build/target/release/openab /usr/local/bin/openab
+
+RUN chown -R agent:agent /home/agent
+
+USER agent
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD pgrep -x openab || exit 1
+ENTRYPOINT ["tini", "--"]
+CMD ["openab", "run", "-c", "/etc/openab/config.toml"]
diff --git a/Dockerfile.native b/Dockerfile.native
new file mode 100644
index 000000000..3879f70e4
--- /dev/null
+++ b/Dockerfile.native
@@ -0,0 +1,36 @@
+# --- Build stage ---
+FROM rust:1-bookworm AS builder
+WORKDIR /build
+COPY Cargo.toml Cargo.lock ./
+COPY openab-agent/ openab-agent/
+RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src
+COPY src/ src/
+RUN touch src/main.rs && cargo build --release
+RUN cd openab-agent && cargo build --release
+
+# --- Runtime stage ---
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep tini && rm -rf /var/lib/apt/lists/*
+
+# Install gh CLI
+RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
+ -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
+ > /etc/apt/sources.list.d/github-cli.list && \
+ apt-get update && apt-get install -y --no-install-recommends gh && \
+ rm -rf /var/lib/apt/lists/*
+
+RUN useradd -m -s /bin/bash -u 1000 agent
+ENV HOME=/home/agent
+WORKDIR /home/agent
+
+COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab
+COPY --from=builder --chown=agent:agent /build/openab-agent/target/release/openab-agent /usr/local/bin/openab-agent
+
+RUN mkdir -p /home/agent/.openab && chown -R agent:agent /home/agent
+
+USER agent
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD pgrep -x openab || exit 1
+ENTRYPOINT ["tini", "--"]
+CMD ["openab", "run", "-c", "/etc/openab/config.toml"]
diff --git a/Dockerfile.opencode b/Dockerfile.opencode
index efbd777f8..9c12a4970 100644
--- a/Dockerfile.opencode
+++ b/Dockerfile.opencode
@@ -26,7 +26,7 @@ FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/*
# Install opencode
-ARG OPENCODE_VERSION=1.14.31
+ARG OPENCODE_VERSION=1.15.7
RUN npm install -g opencode-ai@${OPENCODE_VERSION} --retry 3
# Install gh CLI (matches Dockerfile.claude / Dockerfile.gemini / Dockerfile.codex)
@@ -42,6 +42,8 @@ WORKDIR /home/node
COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab
+RUN mkdir -p /home/node/.opencode && chown -R node:node /home/node/.opencode
+
USER node
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD pgrep -x openab || exit 1
diff --git a/Dockerfile.pi b/Dockerfile.pi
new file mode 100644
index 000000000..5e9f3bba9
--- /dev/null
+++ b/Dockerfile.pi
@@ -0,0 +1,38 @@
+# --- Build stage ---
+FROM rust:1-bookworm AS builder
+WORKDIR /build
+COPY Cargo.toml Cargo.lock ./
+RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src
+COPY src/ src/
+RUN touch src/main.rs && cargo build --release
+
+# --- Runtime stage ---
+FROM node:22-bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep tini bubblewrap socat && rm -rf /var/lib/apt/lists/*
+
+# Install pi-acp adapter and Pi coding agent CLI.
+# The Pi coding agent npm package was renamed from @mariozechner/pi-coding-agent to @earendil-works/pi-coding-agent.
+ARG PI_ACP_VERSION=0.0.27
+ARG PI_CODING_AGENT_VERSION=0.75.5
+RUN npm install -g pi-acp@${PI_ACP_VERSION} @earendil-works/pi-coding-agent@${PI_CODING_AGENT_VERSION} --retry 3
+
+# Install gh CLI (matches other Dockerfiles)
+RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
+ -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
+ > /etc/apt/sources.list.d/github-cli.list && \
+ apt-get update && apt-get install -y --no-install-recommends gh && \
+ rm -rf /var/lib/apt/lists/*
+
+ENV HOME=/home/node
+WORKDIR /home/node
+
+COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab
+
+RUN mkdir -p /home/node/.pi && chown -R node:node /home/node/.pi
+
+USER node
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD pgrep -x openab || exit 1
+ENTRYPOINT ["tini", "--"]
+CMD ["openab", "run", "-c", "/etc/openab/config.toml"]
diff --git a/README.md b/README.md
index c8a9d3d8e..406e7fc91 100644
--- a/README.md
+++ b/README.md
@@ -4,26 +4,26 @@

-A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/).
+A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, Pi, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/).
🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/DmbhfDZjQS)** 🎉
```
-┌──────────────┐ Gateway WS ┌──────────────┐ ACP stdio ┌──────────────┐
-│ Discord │◄─────────────►│ │──────────────►│ coding CLI │
-│ User │ │ openab │◄── JSON-RPC ──│ (acp mode) │
-├──────────────┤ Socket Mode │ (Rust) │ └──────────────┘
-│ Slack │◄─────────────►│ │
-│ User │ └──────┬───────┘
-├──────────────┤ │ WebSocket (outbound)
-│ Telegram │◄──webhook──┐ │
-│ User │ │ │
-├──────────────┤ ▼ ▼
-│ LINE │◄──webhook──┌──────────────────┐
-│ User │ │ Custom Gateway │
-├──────────────┤ │ (standalone) │
-│ Feishu/Lark │◄───WS──────│ │
-│ User │ │ │
+┌──────────────┐ Gateway WS ┌──────────────┐ ACP stdio ┌──────────────────┐
+│ Discord │◄─────────────►│ │──────────────►│ coding CLI │
+│ User │ │ openab │◄── JSON-RPC ──│ (acp mode) │
+├──────────────┤ Socket Mode │ (Rust) │ ├──────────────────┤
+│ Slack │◄─────────────►│ │ │ kiro-cli acp │
+│ User │ └──────┬───────┘ │ claude-agent-acp │
+├──────────────┤ │ WebSocket │ codex-acp │
+│ Telegram │◄──webhook──┐ │ (outbound) │ gemini --acp │
+│ User │ │ │ │ copilot --acp │
+├──────────────┤ ▼ ▼ │ hermes-acp │
+│ LINE │◄──webhook──┌──────────────────┐ │ opencode acp │
+│ User │ │ Custom Gateway │ │ grok agent stdio │
+├──────────────┤ │ (standalone) │ │ agy-acp │
+│ Feishu/Lark │◄───WS──────│ │ │ pi-acp │
+│ User │ │ │ └──────────────────┘
├──────────────┤ │ │
│ Google Chat │◄──webhook──│ │
│ User │ └──────────────────┘
@@ -38,10 +38,11 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**,
- **Multi-platform** — supports Discord and Slack, run one or both simultaneously
- **Custom Gateway** — extend to Telegram, LINE, Feishu/Lark, Google Chat, MS Teams via standalone [gateway](gateway/)
-- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI via config
+- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, Pi via config
- **@mention trigger** — mention the bot in an allowed channel to start a conversation
- **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups
- **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md))
+- **Agent-controlled reply-to** — agents choose which message to reply to via `[[reply_to:id]]` directive, enabling clear conversation threads in multi-bot channels ([docs/output-directives.md](docs/output-directives.md))
- **Edit-streaming** — live-updates the Discord message every 1.5s as tokens arrive
- **Emoji status reactions** — 👀→🤔→🔥/👨💻/⚡→👍+random mood face
- **Image & file support** — send images and files through chat ([docs/sendimages.md](docs/sendimages.md), [docs/sendfiles.md](docs/sendfiles.md))
@@ -111,6 +112,13 @@ See [docs/google-chat.md](docs/google-chat.md) for the full setup guide. Require
+
+WeCom (企业微信) (via Custom Gateway)
+
+See [docs/wecom.md](docs/wecom.md) for the full setup guide. Requires the standalone [Custom Gateway](gateway/) service.
+
+
+
### 2. Install with Helm (Kiro CLI — default)
```bash
@@ -129,6 +137,8 @@ helm install openab openab/openab \
--set-string 'agents.kiro.slack.allowedChannels[0]=C0123456789'
```
+For additional Helm values such as `fullnameOverride`, `nameOverride`, `envFrom`, and `agentsMd`, see [charts/openab/README.md](charts/openab/README.md).
+
### 3. Authenticate (first time only)
```bash
@@ -158,6 +168,11 @@ The bot creates a thread. After that, just type in the thread — no @mention ne
| OpenCode | `opencode acp` | Native | [docs/opencode.md](docs/opencode.md) |
| Copilot CLI ⚠️ | `copilot --acp --stdio` | Native | [docs/copilot.md](docs/copilot.md) |
| Cursor | `cursor-agent acp` | Native | [docs/cursor.md](docs/cursor.md) |
+| Hermes Agent | `hermes-acp` | Native | [docs/hermes.md](docs/hermes.md) |
+| Grok Build | `grok agent stdio` | Native | [docs/grok.md](docs/grok.md) |
+| Antigravity | `agy-acp` | [agy-acp](agy-acp/) | [docs/antigravity.md](docs/antigravity.md) |
+| Pi | `pi-acp` | [pi-acp](https://www.npmjs.com/package/pi-acp) | [docs/pi.md](docs/pi.md) |
+| **Native Agent** | `openab-agent` | Built-in (Rust) | [docs/native-agent.md](docs/native-agent.md) |
> 🔧 Running multiple agents? See [docs/multi-agent.md](docs/multi-agent.md)
diff --git a/agy-acp/.gitignore b/agy-acp/.gitignore
new file mode 100644
index 000000000..eb5a316cb
--- /dev/null
+++ b/agy-acp/.gitignore
@@ -0,0 +1 @@
+target
diff --git a/agy-acp/Cargo.lock b/agy-acp/Cargo.lock
new file mode 100644
index 000000000..a40584813
--- /dev/null
+++ b/agy-acp/Cargo.lock
@@ -0,0 +1,654 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "agy-acp"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tokio",
+ "uuid",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "uuid"
+version = "1.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
+dependencies = [
+ "getrandom",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/agy-acp/Cargo.toml b/agy-acp/Cargo.toml
new file mode 100644
index 000000000..681898924
--- /dev/null
+++ b/agy-acp/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "agy-acp"
+version = "0.1.0"
+edition = "2021"
+license = "MIT"
+description = "ACP stdio adapter for Google Antigravity CLI (agy)"
+
+[dependencies]
+tokio = { version = "1", features = ["full"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+uuid = { version = "1", features = ["v4"] }
diff --git a/agy-acp/src/main.rs b/agy-acp/src/main.rs
new file mode 100644
index 000000000..51ede1e43
--- /dev/null
+++ b/agy-acp/src/main.rs
@@ -0,0 +1,254 @@
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::collections::HashMap;
+use std::io::{self, BufRead, Write};
+use std::path::PathBuf;
+use tokio::process::Command;
+use tokio::sync::mpsc;
+use uuid::Uuid;
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcRequest {
+ id: Option,
+ method: Option,
+ params: Option,
+}
+
+#[derive(Debug, Serialize)]
+struct JsonRpcResponse {
+ jsonrpc: &'static str,
+ id: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ result: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+}
+
+#[derive(Debug, Serialize)]
+struct JsonRpcNotification {
+ jsonrpc: &'static str,
+ method: String,
+ params: Value,
+}
+
+struct Session {
+ /// agy conversation ID (from conversations directory)
+ conversation_id: Option,
+ /// cumulative stdout length from previous turns
+ prev_output_len: usize,
+}
+
+struct Adapter {
+ sessions: HashMap,
+ working_dir: String,
+ conversations_dir: PathBuf,
+}
+
+impl Adapter {
+ fn new() -> Self {
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
+ Self {
+ sessions: HashMap::new(),
+ working_dir: std::env::current_dir()
+ .map(|p| p.to_string_lossy().to_string())
+ .unwrap_or_else(|_| "/tmp".to_string()),
+ conversations_dir: PathBuf::from(&home)
+ .join(".gemini/antigravity-cli/conversations"),
+ }
+ }
+
+ /// Find the most recently modified conversation ID from agy's data dir.
+ fn latest_conversation_id(&self) -> Option {
+ let entries = std::fs::read_dir(&self.conversations_dir).ok()?;
+ entries
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().extension().map(|x| x == "pb").unwrap_or(false))
+ .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
+ .and_then(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_string()))
+ }
+
+ fn handle_initialize(&self, id: u64) -> JsonRpcResponse {
+ JsonRpcResponse {
+ jsonrpc: "2.0",
+ id,
+ result: Some(json!({
+ "protocolVersion": 1,
+ "agentInfo": { "name": "agy", "version": env!("CARGO_PKG_VERSION") },
+ "agentCapabilities": { "streaming": true, "loadSession": false },
+ })),
+ error: None,
+ }
+ }
+
+ fn handle_session_new(&mut self, id: u64) -> JsonRpcResponse {
+ let session_id = Uuid::new_v4().to_string();
+ self.sessions.insert(session_id.clone(), Session {
+ conversation_id: None,
+ prev_output_len: 0,
+ });
+ JsonRpcResponse {
+ jsonrpc: "2.0",
+ id,
+ result: Some(json!({ "sessionId": session_id })),
+ error: None,
+ }
+ }
+
+ async fn handle_session_prompt(&mut self, id: u64, params: &Value) -> Vec {
+ let session_id = params.get("sessionId").and_then(|v| v.as_str()).unwrap_or("");
+ let prompt_text = params
+ .get("prompt")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
+ .collect::>()
+ .join("\n")
+ })
+ .unwrap_or_default();
+ let clean_prompt = prompt_text.trim();
+
+ // Build args: use --conversation for subsequent turns
+ let mut args: Vec = Vec::new();
+ // Always add working dir as workspace so agy reads AGENTS.md/GEMINI.md
+ args.push("--add-dir".to_string());
+ args.push(self.working_dir.clone());
+ // Add extra args from AGY_EXTRA_ARGS env var if set
+ if let Ok(extra) = std::env::var("AGY_EXTRA_ARGS") {
+ args.extend(extra.split_whitespace().map(String::from));
+ }
+ if let Some(session) = self.sessions.get(session_id) {
+ if let Some(conv_id) = &session.conversation_id {
+ args.push("--conversation".to_string());
+ args.push(conv_id.clone());
+ }
+ }
+ args.push("-p".to_string());
+ args.push(clean_prompt.to_string());
+
+ let result = Command::new("agy")
+ .args(&args)
+ .current_dir(&self.working_dir)
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::null())
+ .output()
+ .await;
+
+ let mut output_lines = Vec::new();
+
+ match result {
+ Ok(output) => {
+ let full_text = String::from_utf8_lossy(&output.stdout).to_string();
+
+ // Extract only the new content (delta)
+ let prev_len = self.sessions.get(session_id)
+ .map(|s| s.prev_output_len)
+ .unwrap_or(0);
+ let new_text = if prev_len < full_text.len() {
+ full_text[prev_len..].trim_start().to_string()
+ } else {
+ full_text.clone()
+ };
+
+ // Update session state
+ let conv_id = if self.sessions.get(session_id)
+ .map(|s| s.conversation_id.is_none())
+ .unwrap_or(false)
+ {
+ self.latest_conversation_id()
+ } else {
+ None
+ };
+
+ if let Some(session) = self.sessions.get_mut(session_id) {
+ session.prev_output_len = full_text.len();
+ if session.conversation_id.is_none() {
+ session.conversation_id = conv_id;
+ }
+ }
+
+ let notification = serde_json::to_string(&JsonRpcNotification {
+ jsonrpc: "2.0",
+ method: "session/update".to_string(),
+ params: json!({
+ "sessionId": session_id,
+ "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "content": { "type": "text", "text": new_text },
+ },
+ }),
+ }).unwrap();
+ output_lines.push(notification);
+ let resp = JsonRpcResponse { jsonrpc: "2.0", id, result: Some(json!({ "stopReason": "end_turn" })), error: None };
+ output_lines.push(serde_json::to_string(&resp).unwrap());
+ }
+ Err(e) => {
+ let resp = JsonRpcResponse { jsonrpc: "2.0", id, result: None, error: Some(json!({"code":-32000,"message":format!("failed to run agy: {e}")})) };
+ output_lines.push(serde_json::to_string(&resp).unwrap());
+ }
+ }
+ output_lines
+ }
+}
+
+#[tokio::main]
+async fn main() {
+ let mut adapter = Adapter::new();
+
+ let (tx, mut rx) = mpsc::unbounded_channel::();
+ std::thread::spawn(move || {
+ let stdin = io::stdin();
+ for line in stdin.lock().lines() {
+ match line {
+ Ok(l) if !l.trim().is_empty() => {
+ if tx.send(l).is_err() {
+ break;
+ }
+ }
+ Err(_) => break,
+ _ => {}
+ }
+ }
+ });
+
+ let mut stdout = io::stdout();
+
+ while let Some(line) = rx.recv().await {
+ let req: JsonRpcRequest = match serde_json::from_str(&line) {
+ Ok(r) => r,
+ Err(_) => continue,
+ };
+ let id = match req.id {
+ Some(id) => id,
+ None => continue,
+ };
+
+ let output = match req.method.as_deref() {
+ Some("initialize") => {
+ vec![serde_json::to_string(&adapter.handle_initialize(id)).unwrap()]
+ }
+ Some("session/new") => {
+ vec![serde_json::to_string(&adapter.handle_session_new(id)).unwrap()]
+ }
+ Some("session/prompt") => {
+ let params = req.params.unwrap_or(json!({}));
+ adapter.handle_session_prompt(id, ¶ms).await
+ }
+ Some("session/cancel") => {
+ let r = JsonRpcResponse { jsonrpc: "2.0", id, result: Some(json!({})), error: None };
+ vec![serde_json::to_string(&r).unwrap()]
+ }
+ Some(method) => {
+ let r = JsonRpcResponse { jsonrpc: "2.0", id, result: None, error: Some(json!({"code":-32601,"message":format!("method not found: {method}")})) };
+ vec![serde_json::to_string(&r).unwrap()]
+ }
+ None => continue,
+ };
+
+ for line in output {
+ let _ = writeln!(stdout, "{}", line);
+ }
+ let _ = stdout.flush();
+ }
+}
diff --git a/charts/openab-feishu/Chart.yaml b/charts/openab-feishu/Chart.yaml
new file mode 100644
index 000000000..7904d83aa
--- /dev/null
+++ b/charts/openab-feishu/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: openab-feishu
+description: OpenAB + Feishu/Lark — single-pod deployment with gateway (WebSocket by default, optional Cloudflare Tunnel for webhook mode).
+type: application
+version: 0.1.0
+appVersion: "0.8.3"
diff --git a/charts/openab-feishu/README.md b/charts/openab-feishu/README.md
new file mode 100644
index 000000000..69711e5ef
--- /dev/null
+++ b/charts/openab-feishu/README.md
@@ -0,0 +1,220 @@
+# openab-feishu
+
+OpenAB + Feishu/Lark — single-pod Helm chart for deploying an AI agent on Feishu (飛書) or Lark.
+
+## Architecture
+
+```
+┌──────────── Pod: openab-feishu ────────────┐
+│ │
+│ ┌──────────┐ localhost ┌─────────────┐ │
+│ │ openab │ ◄──────────► │ gateway │ │
+│ │ (agent) │ │ (feishu) │ │
+│ └──────────┘ └──────┬──────┘ │
+│ │ │ │
+│ ▼ ▼ │
+│ PVC /home/agent Outbound WebSocket │
+│ to open.feishu.cn │
+└─────────────────────────────────────────────┘
+```
+
+**Default mode: WebSocket** — gateway connects outbound to Feishu, no public endpoint needed.
+
+Optional webhook mode adds a cloudflared sidecar (same pattern as `openab-telegram`).
+
+## Quick Start
+
+```bash
+helm install my-bot ./charts/openab-feishu \
+ --set feishu.appId="cli_xxx" \
+ --set feishu.appSecret="xxx" \
+ --namespace openab --create-namespace
+```
+
+Only **2 required parameters**. Everything else has sane defaults.
+
+## Prerequisites: Feishu Open Platform Setup
+
+Before deploying, create a Feishu app. This is a one-time setup.
+
+> **Hermes Agent** and **OpenClaw** both support Feishu via their own gateway implementations (env-var based config, `pip install` / `npm install`). OAB's approach is K8s-native: a single `helm install` deploys everything, with credentials managed as K8s Secrets.
+
+### 1. Create App
+
+1. Go to [Feishu Open Platform](https://open.feishu.cn) (or [Lark Developer](https://open.larksuite.com) for overseas)
+2. Click **Create Custom App**
+3. Note down the **App ID** (`cli_xxx`) and **App Secret**
+
+### 2. Enable Bot Capability
+
+1. In the left sidebar, go to **App Features** → **Bot**
+2. Toggle **Enable Bot** to ON
+
+> ⚠️ This step is easy to miss. Without it, the app cannot receive messages.
+
+### 3. Configure Event Subscription
+
+1. Navigate to **Event Subscriptions** in the left sidebar
+2. **Connection mode**: Select **WebSocket** (recommended)
+ - WebSocket requires no public URL — the gateway connects outbound
+ - If you must use webhook mode, see [Webhook Mode](#webhook-mode) below
+3. **Add event**: `im.message.receive_v1` (Receive messages)
+
+### 4. Add Permissions
+
+Under **Permissions & Scopes**, add these scopes:
+
+| Scope | Purpose |
+|-------|---------|
+| `im:message` | Send and receive messages |
+| `im:message.group_at_msg` | Receive @mention messages in groups |
+| `im:message.group_at_msg:readonly` | Read group @mention messages |
+| `im:message.p2p_msg:readonly` | Read DM messages |
+| `im:resource` | Download images/files from messages |
+| `contact:user.base:readonly` | Resolve user display names |
+
+### 5. Publish
+
+Click **Create Version** → **Apply for publish**. For development, you can use the app in test mode without full approval.
+
+### 6. Get IDs for Access Control (Optional)
+
+To restrict which users/groups can interact with the bot:
+
+- **Group ID** (`oc_xxx`): Open the group → click top-right menu → Settings → Group ID
+- **User Open ID** (`ou_xxx`): Check gateway logs after the user sends a message, or use the Feishu Contact API
+
+## Credential Management
+
+Three options from simplest to most secure:
+
+| # | Method | Security | Notes |
+|---|--------|----------|-------|
+| 1 | `--set feishu.appId=X --set feishu.appSecret=Y` | ⚠️ Stored in Helm release | Good for dev/testing |
+| 2 | `kubectl create secret` + `--set existingSecret=name` | ✅ Out of Helm values | Good for production |
+| 3 | `kubectl create secret --from-env-file=<(vault/aws sm)` + `--set existingSecret=name` | ✅✅ Never touches disk | Best for security |
+
+### Option 2 example:
+
+```bash
+kubectl create secret generic feishu-creds -n openab \
+ --from-literal=feishu-app-id="cli_xxx" \
+ --from-literal=feishu-app-secret="xxx"
+
+helm install my-bot ./charts/openab-feishu \
+ --set existingSecret=feishu-creds \
+ --namespace openab --create-namespace
+```
+
+### Option 3 example (AWS Secrets Manager):
+
+```bash
+kubectl create secret generic feishu-creds -n openab \
+ --from-env-file=<(aws secretsmanager get-secret-value \
+ --secret-id oab-feishu --query SecretString --output text | \
+ jq -r '{"feishu-app-id": .appId, "feishu-app-secret": .appSecret} | to_entries[] | "\(.key)=\(.value)"')
+
+helm install my-bot ./charts/openab-feishu \
+ --set existingSecret=feishu-creds \
+ --namespace openab --create-namespace
+```
+
+## Release Channel
+
+| `channel` | Core image tag | Gateway image tag |
+|-----------|---------------|-------------------|
+| `stable` (default) | `ghcr.io/openabdev/openab:stable` | `v0.5.1` (pinned) |
+| `beta` | `ghcr.io/openabdev/openab:beta` | `v0.5.1` (pinned) |
+
+## Webhook Mode
+
+If WebSocket is not available (e.g., network policy blocks outbound WebSocket), switch to webhook mode:
+
+```bash
+helm install my-bot ./charts/openab-feishu \
+ --set feishu.appId="cli_xxx" \
+ --set feishu.appSecret="xxx" \
+ --set feishu.connectionMode="webhook" \
+ --set feishu.verificationToken="xxx" \
+ --set feishu.encryptKey="xxx" \
+ --set tunnel.token="eyJ..." \
+ --set webhookDomain="bot.example.com" \
+ --namespace openab --create-namespace
+```
+
+This adds a cloudflared sidecar (3-container pod, same as `openab-telegram`).
+
+After deployment:
+1. Configure Cloudflare Tunnel ingress to point your domain at `localhost:8080`
+2. In Feishu Open Platform → Event Subscriptions → set Request URL to `https://bot.example.com/webhook/feishu`
+ - ⚠️ The gateway must be running when you set the URL — Feishu sends a challenge request immediately
+
+## Lark (Overseas)
+
+For Lark (larksuite.com) instead of Feishu (feishu.cn):
+
+```bash
+helm install my-bot ./charts/openab-feishu \
+ --set feishu.appId="cli_xxx" \
+ --set feishu.appSecret="xxx" \
+ --set feishu.domain="lark" \
+ --namespace openab --create-namespace
+```
+
+## Comparison with Other Platforms
+
+| Feature | openab-feishu | openab-telegram | OpenClaw | Hermes Agent |
+|---------|--------------|-----------------|----------|--------------|
+| Default containers | 2 (agent + gateway) | 3 (+ cloudflared) | N/A (no Helm) | N/A (no Helm) |
+| Public endpoint needed | ❌ (WebSocket) | ✅ (webhook) | Varies | Varies |
+| Feishu/Lark support | ✅ Native | ❌ | ❌ | ❌ |
+| K8s-native deployment | ✅ Helm chart | ✅ Helm chart | ❌ docker-compose | ❌ pip install |
+| Credential params | 2 (appId + appSecret) | 2 (botToken + tunnelToken) | N/A | N/A |
+
+## Values Reference
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `feishu.appId` | `""` | **(required)** Feishu App ID |
+| `feishu.appSecret` | `""` | **(required)** Feishu App Secret |
+| `feishu.domain` | `"feishu"` | `"feishu"` or `"lark"` |
+| `feishu.connectionMode` | `"websocket"` | `"websocket"` or `"webhook"` |
+| `feishu.verificationToken` | `""` | Webhook verification token |
+| `feishu.encryptKey` | `""` | Webhook encrypt key |
+| `existingSecret` | `""` | Use pre-existing K8s Secret |
+| `tunnel.enabled` | `false` | Enable cloudflared sidecar |
+| `tunnel.token` | `""` | Cloudflare Tunnel token |
+| `webhookDomain` | `""` | Domain for webhook URL |
+| `channel` | `"stable"` | `"stable"` or `"beta"` |
+| `platform.requireMention` | `true` | Require @mention in groups |
+| `platform.allowedGroups` | `[]` | Allowed group chat IDs |
+| `platform.allowedUsers` | `[]` | Allowed user open_ids |
+| `persistence.enabled` | `true` | Enable PVC for agent state |
+| `persistence.size` | `"1Gi"` | PVC size |
+
+## Troubleshooting
+
+| Problem | Fix |
+|---------|-----|
+| Bot doesn't respond to DMs | Ensure Bot capability is enabled in App Features → Bot |
+| Bot doesn't respond in groups | Ensure you @mention the bot (default: `requireMention: true`) |
+| Bot doesn't receive any messages | Check event subscription: must have `im.message.receive_v1` and WebSocket mode selected |
+| Gateway logs show "token refresh error" | Verify `appId` and `appSecret` are correct |
+| Gateway logs show "feishu ws endpoint error" | WebSocket mode requires the app to be published (at least test version) |
+| Permission denied on image/file download | Grant `im:resource` scope and re-publish the app |
+| User names show as `ou_xxx` | Grant `contact:user.base:readonly` scope |
+| Pod CrashLoopBackOff | Check `kubectl logs -c gateway` — usually a credential issue |
+
+## Comparison: OAB vs OpenClaw vs Hermes Agent (Feishu)
+
+| Aspect | OAB (this chart) | OpenClaw | Hermes Agent |
+|--------|-----------------|----------|--------------|
+| Deployment | `helm install` (K8s-native) | `npx @larksuite/openclaw-lark install` | `hermes gateway setup` |
+| Runtime | Rust binary (gateway) + any agent | Node.js | Python |
+| Connection mode | WebSocket (default) / Webhook | WebSocket (default) / Webhook | WebSocket (default) / Webhook |
+| Config style | Helm values + K8s Secrets | JSON config file | `.env` + `config.yaml` |
+| Credential management | 3-tier (--set → K8s Secret → external SM) | Plain config file | `.env` file |
+| Security hardening | Non-root, read-only rootfs, drop all caps | N/A (runs as user process) | N/A (runs as user process) |
+| Public endpoint needed | ❌ (WebSocket mode) | ❌ (WebSocket mode) | ❌ (WebSocket mode) |
+| Feishu-specific features | @mention gating, user allowlist, group allowlist, bot-to-bot, media proxy | Streaming cards, multi-account, ACP sessions, pairing | Interactive cards, document comments, per-group ACL, burst batching |
+
diff --git a/charts/openab-feishu/templates/NOTES.txt b/charts/openab-feishu/templates/NOTES.txt
new file mode 100644
index 000000000..f25b8373f
--- /dev/null
+++ b/charts/openab-feishu/templates/NOTES.txt
@@ -0,0 +1,73 @@
+🎉 OpenAB Feishu bot deployed!
+
+{{- if include "openab-feishu.tunnelEnabled" . }}
+Pod: {{ include "openab-feishu.fullname" . }} (3 containers: openab, gateway, cloudflared)
+Mode: webhook (with Cloudflare Tunnel)
+{{- else }}
+Pod: {{ include "openab-feishu.fullname" . }} (2 containers: openab, gateway)
+Mode: websocket (outbound-only, no public endpoint needed)
+{{- end }}
+
+## Post-Install Steps
+
+{{- if eq .Values.feishu.connectionMode "websocket" }}
+
+### Step 1: Configure Feishu Open Platform (one-time)
+
+1. Go to https://open.feishu.cn → Create or select your app
+2. Under "Event Subscriptions" → Connection mode: select "WebSocket"
+3. Subscribe to event: im.message.receive_v1
+4. Under "Permissions", add:
+ - im:message (Send and receive messages)
+ - im:message.group_at_msg (Receive group @messages)
+ - im:resource (Download message resources)
+ - contact:user.base:readonly (Read user basic info — for display names)
+5. Publish the app version
+
+No public URL or webhook configuration needed — the gateway connects outbound.
+
+{{- else }}
+
+### Step 1: Configure Cloudflare Tunnel ingress
+
+Option A — Via Cloudflare Dashboard:
+ https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add:
+ Hostname: {{ .Values.webhookDomain | default "bot.example.com" }}
+ Type: HTTP
+ URL: localhost:8080
+
+### Step 2: Configure Feishu Open Platform
+
+1. Go to https://open.feishu.cn → Create or select your app
+2. Under "Event Subscriptions" → Connection mode: select "HTTP"
+3. Set Request URL: https://{{ .Values.webhookDomain | default "YOUR_DOMAIN" }}/webhook/feishu
+ (The gateway must be running — Feishu sends a challenge request immediately)
+4. Subscribe to event: im.message.receive_v1
+5. Under "Permissions", add:
+ - im:message
+ - im:message.group_at_msg
+ - im:resource
+ - contact:user.base:readonly
+6. Publish the app version
+
+{{- end }}
+
+### {{ if eq .Values.feishu.connectionMode "websocket" }}Step 2{{ else }}Step 3{{ end }}: Authenticate the agent
+
+The agent needs a one-time OAuth login:
+
+ kubectl exec -it deployment/{{ include "openab-feishu.fullname" . }} -n {{ .Release.Namespace }} -c openab -- {{ .Values.agent.command }} login --use-device-flow
+
+Then restart to pick up credentials:
+
+ kubectl rollout restart deployment/{{ include "openab-feishu.fullname" . }} -n {{ .Release.Namespace }}
+
+## Verify
+
+Send a message to your bot on Feishu (DM or @mention in a group). Check logs if no response:
+
+ kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c openab --tail=20
+ kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c gateway --tail=20
+{{- if include "openab-feishu.tunnelEnabled" . }}
+ kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c cloudflared --tail=20
+{{- end }}
diff --git a/charts/openab-feishu/templates/_helpers.tpl b/charts/openab-feishu/templates/_helpers.tpl
new file mode 100644
index 000000000..f0c4a85d6
--- /dev/null
+++ b/charts/openab-feishu/templates/_helpers.tpl
@@ -0,0 +1,41 @@
+{{- define "openab-feishu.fullname" -}}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{- define "openab-feishu.labels" -}}
+helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }}
+app.kubernetes.io/name: {{ .Chart.Name }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{- define "openab-feishu.selectorLabels" -}}
+app.kubernetes.io/name: {{ .Chart.Name }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{- define "openab-feishu.agentImage" -}}
+{{- $tag := .Values.image.tag -}}
+{{- if not $tag -}}
+ {{- $tag = .Values.channel | default "stable" -}}
+{{- end -}}
+{{- printf "%s:%s" .Values.image.repository $tag -}}
+{{- end }}
+
+{{- define "openab-feishu.gatewayImage" -}}
+{{- printf "%s:%s" .Values.gateway.image .Values.gateway.tag -}}
+{{- end }}
+
+{{- define "openab-feishu.secretName" -}}
+{{- .Values.existingSecret | default (include "openab-feishu.fullname" .) -}}
+{{- end }}
+
+{{- define "openab-feishu.tunnelEnabled" -}}
+{{- if .Values.tunnel.enabled -}}
+true
+{{- else if and (eq .Values.feishu.connectionMode "webhook") .Values.tunnel.token -}}
+true
+{{- else -}}
+{{- end -}}
+{{- end }}
diff --git a/charts/openab-feishu/templates/configmap.yaml b/charts/openab-feishu/templates/configmap.yaml
new file mode 100644
index 000000000..baf3fe999
--- /dev/null
+++ b/charts/openab-feishu/templates/configmap.yaml
@@ -0,0 +1,36 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "openab-feishu.fullname" . }}
+ labels:
+ {{- include "openab-feishu.labels" . | nindent 4 }}
+data:
+ config.toml: |
+ [agent]
+ command = {{ .Values.agent.command | toJson }}
+ args = {{ .Values.agent.args | default list | toJson }}
+ working_dir = {{ .Values.agent.workingDir | default "/home/agent" | toJson }}
+ {{- if .Values.agent.env }}
+ env = { {{ range $i, $k := (.Values.agent.env | keys | sortAlpha) }}{{ if gt $i 0 }}, {{ end }}{{ $k }} = {{ index $.Values.agent.env $k | toJson }}{{ end }} }
+ {{- end }}
+ {{- $secretEnvKeys := list }}
+ {{- range .Values.agent.secretEnv }}{{ $secretEnvKeys = append $secretEnvKeys .name }}{{ end }}
+ {{- if $secretEnvKeys }}
+ inherit_env = {{ $secretEnvKeys | toJson }}
+ {{- end }}
+
+ [pool]
+ max_sessions = {{ .Values.agent.pool.maxSessions | default 10 }}
+ session_ttl_hours = {{ .Values.agent.pool.sessionTtlHours | default 24 }}
+
+ [reactions]
+ enabled = {{ .Values.agent.reactions.enabled }}
+ remove_after_reply = {{ .Values.agent.reactions.removeAfterReply }}
+
+ [gateway]
+ url = "ws://localhost:8080/ws"
+ platform = "feishu"
+ allow_all_channels = {{ if .Values.platform.allowedGroups }}false{{ else }}true{{ end }}
+ allowed_channels = {{ .Values.platform.allowedGroups | default list | toJson }}
+ allow_all_users = {{ if .Values.platform.allowedUsers }}false{{ else }}true{{ end }}
+ allowed_users = {{ .Values.platform.allowedUsers | default list | toJson }}
diff --git a/charts/openab-feishu/templates/deployment.yaml b/charts/openab-feishu/templates/deployment.yaml
new file mode 100644
index 000000000..dbadc7962
--- /dev/null
+++ b/charts/openab-feishu/templates/deployment.yaml
@@ -0,0 +1,187 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "openab-feishu.fullname" . }}
+ labels:
+ {{- include "openab-feishu.labels" . | nindent 4 }}
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ {{- include "openab-feishu.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ annotations:
+ checksum/config: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }}
+ {{- if not .Values.existingSecret }}
+ checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }}
+ {{- end }}
+ labels:
+ {{- include "openab-feishu.selectorLabels" . | nindent 8 }}
+ spec:
+ {{- with .Values.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ # --- OAB agent (main) ---
+ - name: openab
+ image: {{ include "openab-feishu.agentImage" . | quote }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ {{- with .Values.containerSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ env:
+ - name: HOME
+ value: {{ .Values.agent.workingDir | default "/home/agent" }}
+ {{- range $k, $v := .Values.agent.env }}
+ - name: {{ $k }}
+ value: {{ $v | quote }}
+ {{- end }}
+ {{- range .Values.agent.secretEnv }}
+ - name: {{ .name }}
+ valueFrom:
+ secretKeyRef:
+ name: {{ .secretName }}
+ key: {{ .secretKey }}
+ {{- end }}
+ {{- with .Values.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ volumeMounts:
+ - name: config
+ mountPath: /etc/openab
+ readOnly: true
+ {{- if .Values.persistence.enabled }}
+ - name: data
+ mountPath: {{ .Values.agent.workingDir | default "/home/agent" }}
+ {{- end }}
+ - name: tmp
+ mountPath: /tmp
+
+ # --- Gateway (sidecar) ---
+ - name: gateway
+ image: {{ include "openab-feishu.gatewayImage" . | quote }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ {{- with .Values.containerSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ ports:
+ - name: http
+ containerPort: 8080
+ protocol: TCP
+ env:
+ - name: HOME
+ value: {{ .Values.agent.workingDir | default "/home/agent" }}
+ - name: FEISHU_APP_ID
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab-feishu.secretName" . }}
+ key: feishu-app-id
+ - name: FEISHU_APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab-feishu.secretName" . }}
+ key: feishu-app-secret
+ - name: FEISHU_DOMAIN
+ value: {{ .Values.feishu.domain | quote }}
+ - name: FEISHU_CONNECTION_MODE
+ value: {{ .Values.feishu.connectionMode | quote }}
+ {{- if or .Values.feishu.verificationToken (eq .Values.feishu.connectionMode "webhook") }}
+ - name: FEISHU_VERIFICATION_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab-feishu.secretName" . }}
+ key: feishu-verification-token
+ optional: true
+ {{- end }}
+ {{- if or .Values.feishu.encryptKey (eq .Values.feishu.connectionMode "webhook") }}
+ - name: FEISHU_ENCRYPT_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab-feishu.secretName" . }}
+ key: feishu-encrypt-key
+ optional: true
+ {{- end }}
+ {{- if .Values.platform.requireMention }}
+ - name: FEISHU_REQUIRE_MENTION
+ value: "true"
+ {{- else }}
+ - name: FEISHU_REQUIRE_MENTION
+ value: "false"
+ {{- end }}
+ {{- if .Values.platform.allowedGroups }}
+ - name: FEISHU_ALLOWED_GROUPS
+ value: {{ join "," .Values.platform.allowedGroups | quote }}
+ {{- end }}
+ {{- if .Values.platform.allowedUsers }}
+ - name: FEISHU_ALLOWED_USERS
+ value: {{ join "," .Values.platform.allowedUsers | quote }}
+ {{- end }}
+ volumeMounts:
+ {{- if .Values.persistence.enabled }}
+ - name: data
+ mountPath: {{ .Values.agent.workingDir | default "/home/agent" }}
+ {{- end }}
+ - name: tmp
+ mountPath: /tmp
+
+ {{- if include "openab-feishu.tunnelEnabled" . }}
+ {{- if and (not .Values.tunnel.token) (not .Values.existingSecret) }}
+ {{- fail "tunnel.token is required when the cloudflared tunnel is enabled (set tunnel.token, or supply credentials via existingSecret with a cloudflare-tunnel-token key)" }}
+ {{- end }}
+ # --- Cloudflared tunnel (sidecar, webhook mode only) ---
+ - name: cloudflared
+ image: {{ printf "%s:%s" .Values.tunnel.image .Values.tunnel.tag }}
+ imagePullPolicy: IfNotPresent
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - ALL
+ args:
+ - tunnel
+ - --no-autoupdate
+ - run
+ - --token
+ - $(TUNNEL_TOKEN)
+ env:
+ - name: TUNNEL_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab-feishu.secretName" . }}
+ key: cloudflare-tunnel-token
+ volumeMounts:
+ - name: tmp
+ mountPath: /tmp
+ {{- end }}
+
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ volumes:
+ - name: config
+ configMap:
+ name: {{ include "openab-feishu.fullname" . }}
+ {{- if .Values.persistence.enabled }}
+ - name: data
+ persistentVolumeClaim:
+ claimName: {{ .Values.persistence.existingClaim | default (include "openab-feishu.fullname" .) }}
+ {{- end }}
+ - name: tmp
+ emptyDir: {}
diff --git a/charts/openab-feishu/templates/pvc.yaml b/charts/openab-feishu/templates/pvc.yaml
new file mode 100644
index 000000000..0cca83059
--- /dev/null
+++ b/charts/openab-feishu/templates/pvc.yaml
@@ -0,0 +1,23 @@
+{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ include "openab-feishu.fullname" . }}
+ annotations:
+ "helm.sh/resource-policy": keep
+ labels:
+ {{- include "openab-feishu.labels" . | nindent 4 }}
+spec:
+ accessModes:
+ - ReadWriteOnce
+ {{- if .Values.persistence.storageClass }}
+ {{- if eq "-" .Values.persistence.storageClass }}
+ storageClassName: ""
+ {{- else }}
+ storageClassName: {{ .Values.persistence.storageClass | quote }}
+ {{- end }}
+ {{- end }}
+ resources:
+ requests:
+ storage: {{ .Values.persistence.size | default "1Gi" }}
+{{- end }}
diff --git a/charts/openab-feishu/templates/secret.yaml b/charts/openab-feishu/templates/secret.yaml
new file mode 100644
index 000000000..39f4ae930
--- /dev/null
+++ b/charts/openab-feishu/templates/secret.yaml
@@ -0,0 +1,27 @@
+{{- if not .Values.existingSecret }}
+{{- if not .Values.feishu.appId }}
+{{- fail "feishu.appId is required when existingSecret is not set (--set feishu.appId=YOUR_APP_ID)" }}
+{{- end }}
+{{- if not .Values.feishu.appSecret }}
+{{- fail "feishu.appSecret is required when existingSecret is not set (--set feishu.appSecret=YOUR_APP_SECRET)" }}
+{{- end }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "openab-feishu.fullname" . }}
+ labels:
+ {{- include "openab-feishu.labels" . | nindent 4 }}
+type: Opaque
+stringData:
+ feishu-app-id: {{ .Values.feishu.appId | quote }}
+ feishu-app-secret: {{ .Values.feishu.appSecret | quote }}
+ {{- if .Values.feishu.verificationToken }}
+ feishu-verification-token: {{ .Values.feishu.verificationToken | quote }}
+ {{- end }}
+ {{- if .Values.feishu.encryptKey }}
+ feishu-encrypt-key: {{ .Values.feishu.encryptKey | quote }}
+ {{- end }}
+ {{- if .Values.tunnel.token }}
+ cloudflare-tunnel-token: {{ .Values.tunnel.token | quote }}
+ {{- end }}
+{{- end }}
diff --git a/charts/openab-feishu/values.yaml b/charts/openab-feishu/values.yaml
new file mode 100644
index 000000000..dd9f11c04
--- /dev/null
+++ b/charts/openab-feishu/values.yaml
@@ -0,0 +1,122 @@
+# openab-feishu values
+#
+# Install (WebSocket mode — default, no tunnel needed):
+# helm install my-bot ./charts/openab-feishu \
+# --set feishu.appId="cli_xxx" \
+# --set feishu.appSecret="xxx" \
+# --namespace openab --create-namespace
+#
+# Required:
+# feishu.appId -- Feishu/Lark App ID (from open.feishu.cn)
+# feishu.appSecret -- Feishu/Lark App Secret
+#
+# Optional:
+# feishu.domain -- "feishu" (default, China) or "lark" (overseas)
+# feishu.connectionMode -- "websocket" (default) or "webhook"
+# tunnel.enabled -- Enable cloudflared sidecar (auto-enabled in webhook mode)
+# tunnel.token -- Cloudflare Tunnel token (required if tunnel.enabled)
+
+# -- Feishu/Lark application credentials
+feishu:
+ # -- (required unless existingSecret is set) App ID from Feishu Open Platform
+ appId: ""
+ # -- (required unless existingSecret is set) App Secret
+ appSecret: ""
+ # -- "feishu" for feishu.cn (China) or "lark" for larksuite.com (overseas)
+ domain: "feishu"
+ # -- "websocket" (default, outbound-only, no public endpoint needed) or "webhook"
+ connectionMode: "websocket"
+ # -- (webhook mode only) Verification token from Feishu Open Platform
+ verificationToken: ""
+ # -- (webhook mode only) Encrypt key for event signature verification
+ encryptKey: ""
+
+# -- Use a pre-existing K8s Secret instead of creating one from --set values.
+# The Secret must contain keys: feishu-app-id, feishu-app-secret
+# Optional keys: feishu-verification-token, feishu-encrypt-key, cloudflare-tunnel-token
+#
+# NOTE: When existingSecret is set, the chart cannot track changes to the external
+# Secret's contents — rotating credentials will NOT automatically trigger a Pod
+# rollout. To enable automatic rollout on secret rotation, use a tool such as
+# Reloader (https://github.com/stakater/Reloader) and annotate the Deployment:
+# kubectl annotate deployment reloader.stakater.com/auto="true"
+existingSecret: ""
+
+# -- Cloudflare Tunnel sidecar (only needed for webhook mode)
+tunnel:
+ # -- Enable tunnel sidecar. Auto-enabled when connectionMode=webhook and token is set.
+ enabled: false
+ # -- Cloudflare Tunnel token
+ token: ""
+ image: cloudflare/cloudflared
+ tag: "2026.5.0"
+
+# -- Webhook domain (shown in post-install notes)
+webhookDomain: ""
+
+# -- Release channel: "stable" or "beta"
+channel: stable
+
+# -- OAB agent image
+image:
+ repository: ghcr.io/openabdev/openab
+ tag: "" # defaults to channel
+ pullPolicy: IfNotPresent
+
+# -- Gateway image
+gateway:
+ image: ghcr.io/openabdev/openab-gateway
+ tag: "v0.5.1"
+
+# -- Agent configuration
+agent:
+ command: kiro-cli
+ args:
+ - acp
+ - --trust-all-tools
+ workingDir: /home/agent
+ env: {}
+ secretEnv: []
+ pool:
+ maxSessions: 10
+ sessionTtlHours: 24
+ reactions:
+ enabled: true
+ removeAfterReply: false
+
+# -- Gateway platform settings
+platform:
+ # -- Require @mention in groups (recommended for shared groups)
+ requireMention: true
+ # -- Feishu group chat IDs allowed (empty = all groups)
+ allowedGroups: []
+ # -- Feishu user open_ids allowed (empty = all users)
+ allowedUsers: []
+
+# -- Persistence for agent working directory
+persistence:
+ enabled: true
+ existingClaim: ""
+ storageClass: ""
+ size: 1Gi
+
+# -- Pod-level settings
+resources: {}
+nodeSelector: {}
+tolerations: []
+affinity: {}
+
+podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ runAsGroup: 1000
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+
+containerSecurityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - ALL
diff --git a/charts/openab-telegram/Chart.yaml b/charts/openab-telegram/Chart.yaml
new file mode 100644
index 000000000..f147ef0b9
--- /dev/null
+++ b/charts/openab-telegram/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: openab-telegram
+description: OpenAB + Telegram — single-pod deployment with gateway and Cloudflare Tunnel sidecar.
+type: application
+version: 0.1.0
+appVersion: "0.8.3"
diff --git a/charts/openab-telegram/README.md b/charts/openab-telegram/README.md
new file mode 100644
index 000000000..acd29bd73
--- /dev/null
+++ b/charts/openab-telegram/README.md
@@ -0,0 +1,241 @@
+# openab-telegram
+
+OpenAB + Telegram in a single pod — OAB agent, gateway, and Cloudflare Tunnel colocated.
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Pod: openab-telegram │
+│ │
+│ ┌───────────┐ ws://localhost:8080/ws ┌───────────┐ │
+│ │ openab │◄────────────────────────────►│ gateway │ │
+│ │ (agent) │ │ :8080 │ │
+│ └─────┬─────┘ └─────┬─────┘ │
+│ │ │ │
+│ │ /etc/openab/config.toml │ │
+│ │ /home/agent (PVC) │ │
+│ │ │ │
+│ ┌─────┴──────────────────────────────────────────┴─────┐ │
+│ │ localhost │ │
+│ └──────────────────────────┬───────────────────────────┘ │
+│ │ │
+│ ┌────────┴────────┐ │
+│ │ cloudflared │ │
+│ │ (tunnel) │ │
+│ └────────┬────────┘ │
+│ │ │
+└─────────────────────────────┼───────────────────────────────┘
+ │ Cloudflare Tunnel
+ ▼
+ ┌────────────────────────┐
+ │ Cloudflare Edge │
+ │ (bot.example.com) │
+ └────────────┬───────────┘
+ │ HTTPS
+ ▼
+ ┌────────────────────────┐
+ │ Telegram API │
+ │ (webhook delivery) │
+ └────────────────────────┘
+```
+
+## Prerequisites
+
+Run these on your **local machine** (or CI) — one-time setup, no browser required.
+
+### 1. Create a Telegram bot
+
+```bash
+# Use the Telegram Bot API directly (no app needed):
+curl "https://api.telegram.org/bot/sendMessage" \
+ -d "chat_id=@BotFather" -d "text=/newbot"
+
+# Or message @BotFather in Telegram and save the token it returns.
+# The token looks like: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz
+```
+
+### 2. Create a Cloudflare Tunnel (fully headless)
+
+```bash
+# Install cloudflared
+# macOS: brew install cloudflared
+# Linux: curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared
+
+# Authenticate with API token (no browser — create token at https://dash.cloudflare.com/profile/api-tokens or via Terraform)
+# Required permissions: Account:Cloudflare Tunnel:Edit, Zone:DNS:Edit
+export CLOUDFLARE_API_TOKEN="your-api-token"
+
+# Or use service token auth:
+cloudflared tunnel login # only option if no API token; opens browser once
+
+# Create the tunnel
+cloudflared tunnel create my-telegram-bot
+
+# Route DNS (creates CNAME: bot.example.com → .cfargotunnel.com)
+cloudflared tunnel route dns my-telegram-bot bot.example.com
+
+# Configure ingress (what the tunnel serves)
+mkdir -p ~/.cloudflared
+cat > ~/.cloudflared/config.yml < **Expected Secret keys:** `telegram-bot-token`, `cloudflare-tunnel-token`
+
+## Post-Install
+
+### Configure tunnel ingress (required for remote mode)
+
+The chart runs cloudflared in **remote mode** (token-based). Ingress rules must be configured via the Cloudflare API or dashboard — local config files are ignored.
+
+**Option A — API (recommended for AI-assisted installs):**
+
+Add `cloudflare-api-token` to your K8s Secret, then the helm NOTES provide a ready-to-run command. The AI can extract all credentials from the secret and configure ingress automatically.
+
+```bash
+# Add API token to secret (required permissions: Account:Cloudflare Tunnel:Edit)
+kubectl create secret generic my-bot-creds -n openab \
+ --from-literal=telegram-bot-token="123:ABC" \
+ --from-literal=cloudflare-tunnel-token="eyJ..." \
+ --from-literal=cloudflare-api-token="cfut_..."
+
+# Extract IDs and configure
+ACCOUNT_ID=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .a)
+TUNNEL_ID=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .t)
+CF_API_TOKEN=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-api-token}' | base64 -d)
+
+curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{"config":{"ingress":[{"hostname":"bot.example.com","service":"http://localhost:8080"},{"service":"http_status:404"}]}}'
+
+# Restart to pick up ingress
+kubectl rollout restart deployment/my-bot -n openab
+```
+
+**Option B — Dashboard:**
+
+Go to https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add:
+- Hostname: `bot.example.com`
+- Type: HTTP
+- URL: `localhost:8080`
+
+### Authenticate the agent
+
+Kiro CLI requires a one-time OAuth login. The PVC persists tokens across restarts.
+
+```bash
+kubectl exec -it deployment/my-bot -n openab -c openab -- kiro-cli login --use-device-flow
+kubectl rollout restart deployment/my-bot -n openab
+```
+
+## AI-Assisted Install
+
+To have an AI agent handle the full install, prompt it with:
+
+> Follow the openab-telegram chart README at https://github.com/openabdev/openab/blob/main/charts/openab-telegram/README.md to deploy a Telegram bot on my Kubernetes cluster.
+>
+> I already have:
+> - A Telegram bot token: ``
+> - A Cloudflare account with `cloudflared` authenticated
+> - A domain: `bot.example.com`
+> - kubectl access to my cluster
+>
+> Create the tunnel, install the chart, and complete all post-install steps from the helm NOTES output (including configuring tunnel ingress via the API and setting the webhook). Store the cloudflare-api-token in the K8s secret so ingress can be configured programmatically.
+
+## Values
+
+| Key | Required | Default | Description |
+|-----|----------|---------|-------------|
+| `telegramBotToken` | Yes* | `""` | Telegram bot token |
+| `cloudflareTunnelToken` | Yes* | `""` | Cloudflare Tunnel token |
+| `existingSecret` | No | `""` | Pre-existing Secret name (skips token fields) |
+| `webhookDomain` | No | `""` | Shown in post-install notes |
+| `image.repository` | No | `ghcr.io/openabdev/openab` | Agent image |
+| `image.tag` | No | `appVersion` | Agent image tag |
+| `gateway.tag` | No | `v0.5.0` | Gateway image tag |
+| `agent.command` | No | `kiro-cli` | Agent command |
+| `platform.allowAllUsers` | No | `false` | Allow any Telegram user (opt-in) |
+| `platform.allowedUsers` | No | `[]` | Allowed Telegram user IDs (get yours from [@userinfobot](https://t.me/userinfobot)) |
+| `persistence.enabled` | No | `true` | Enable PVC for agent state |
+| `persistence.size` | No | `1Gi` | PVC size |
+
+*Required unless `existingSecret` is set.
diff --git a/charts/openab-telegram/templates/NOTES.txt b/charts/openab-telegram/templates/NOTES.txt
new file mode 100644
index 000000000..73afd02aa
--- /dev/null
+++ b/charts/openab-telegram/templates/NOTES.txt
@@ -0,0 +1,71 @@
+🎉 OpenAB Telegram bot deployed!
+
+Pod: {{ include "openab-telegram.fullname" . }} (3 containers: openab, gateway, cloudflared)
+
+## Post-Install Steps (complete in order)
+
+### Step 1: Configure Cloudflare Tunnel ingress
+
+The tunnel is running but has no ingress rules yet — webhook will return 503 until configured.
+
+Option A — Via Cloudflare API (AI-friendly):
+
+ Extract credentials from the K8s secret:
+ ACCOUNT_ID=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .a)
+ TUNNEL_ID=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .t)
+ CF_API_TOKEN=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-api-token}' | base64 -d)
+
+ Then configure ingress:
+ curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \
+ -H "Authorization: Bearer ${CF_API_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{"config":{"ingress":[{"hostname":"{{ .Values.webhookDomain | default "bot.example.com" }}","service":"http://localhost:8080"},{"service":"http_status:404"}]}}'
+
+ After configuring, restart the pod to pick up the new ingress:
+ kubectl rollout restart deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }}
+
+ Note: cloudflare-api-token is optional in the Secret. If not present, use Option B.
+
+Option B — Via Cloudflare Dashboard:
+ https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add:
+ Hostname: {{ .Values.webhookDomain | default "bot.example.com" }}
+ Type: HTTP
+ URL: localhost:8080
+
+{{- if .Values.webhookDomain }}
+
+### Step 2: Set the Telegram webhook
+
+Run this command to point Telegram at your tunnel:
+
+ curl "https://api.telegram.org/bot$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.telegram-bot-token}' | base64 -d)/setWebhook" \
+ -d "url=https://{{ .Values.webhookDomain }}/webhook/telegram"
+
+{{- else }}
+
+### Step 2: Set the Telegram webhook
+
+ curl "https://api.telegram.org/bot/setWebhook" \
+ -d "url=https://YOUR_DOMAIN/webhook/telegram"
+
+ Tip: pass --set webhookDomain=bot.example.com to get a ready-to-run command here.
+
+{{- end }}
+
+### Step 3: Authenticate the agent
+
+The agent needs a one-time OAuth login. Run the device flow:
+
+ kubectl exec -it deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }} -c openab -- {{ .Values.agent.command }} login --use-device-flow
+
+Then restart to pick up credentials:
+
+ kubectl rollout restart deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }}
+
+## Verify
+
+Send a message to your bot on Telegram. Check logs if no response:
+
+ kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c openab --tail=20
+ kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c gateway --tail=20
+ kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c cloudflared --tail=20
diff --git a/charts/openab-telegram/templates/_helpers.tpl b/charts/openab-telegram/templates/_helpers.tpl
new file mode 100644
index 000000000..2bde55e1d
--- /dev/null
+++ b/charts/openab-telegram/templates/_helpers.tpl
@@ -0,0 +1,32 @@
+{{- define "openab-telegram.fullname" -}}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{- define "openab-telegram.labels" -}}
+helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }}
+app.kubernetes.io/name: {{ .Chart.Name }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{- define "openab-telegram.selectorLabels" -}}
+app.kubernetes.io/name: {{ .Chart.Name }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{- define "openab-telegram.agentImage" -}}
+{{- $tag := .Values.image.tag -}}
+{{- if not $tag -}}
+ {{- $tag = .Values.channel | default "stable" -}}
+{{- end -}}
+{{- printf "%s:%s" .Values.image.repository $tag -}}
+{{- end }}
+
+{{- define "openab-telegram.gatewayImage" -}}
+{{- printf "%s:%s" .Values.gateway.image .Values.gateway.tag -}}
+{{- end }}
+
+{{- define "openab-telegram.secretName" -}}
+{{- .Values.existingSecret | default (include "openab-telegram.fullname" .) -}}
+{{- end }}
\ No newline at end of file
diff --git a/charts/openab-telegram/templates/configmap.yaml b/charts/openab-telegram/templates/configmap.yaml
new file mode 100644
index 000000000..57527fe53
--- /dev/null
+++ b/charts/openab-telegram/templates/configmap.yaml
@@ -0,0 +1,36 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "openab-telegram.fullname" . }}
+ labels:
+ {{- include "openab-telegram.labels" . | nindent 4 }}
+data:
+ config.toml: |
+ [agent]
+ command = {{ .Values.agent.command | toJson }}
+ args = {{ .Values.agent.args | default list | toJson }}
+ working_dir = {{ .Values.agent.workingDir | default "/home/agent" | toJson }}
+ {{- if .Values.agent.env }}
+ env = { {{ $first := true }}{{ range $k, $v := .Values.agent.env }}{{ if not $first }}, {{ end }}{{ $k }} = {{ $v | toJson }}{{ $first = false }}{{ end }} }
+ {{- end }}
+ {{- $secretEnvKeys := list }}
+ {{- range .Values.agent.secretEnv }}{{ $secretEnvKeys = append $secretEnvKeys .name }}{{ end }}
+ {{- if $secretEnvKeys }}
+ inherit_env = {{ $secretEnvKeys | toJson }}
+ {{- end }}
+
+ [pool]
+ max_sessions = {{ .Values.agent.pool.maxSessions | default 10 }}
+ session_ttl_hours = {{ .Values.agent.pool.sessionTtlHours | default 24 }}
+
+ [reactions]
+ enabled = {{ .Values.agent.reactions.enabled | default true }}
+ remove_after_reply = {{ .Values.agent.reactions.removeAfterReply | default false }}
+
+ [gateway]
+ url = "ws://localhost:8080/ws"
+ platform = "telegram"
+ allow_all_channels = {{ if hasKey .Values.platform "allowAllChannels" }}{{ .Values.platform.allowAllChannels }}{{ else }}true{{ end }}
+ allowed_channels = {{ .Values.platform.allowedChannels | default list | toJson }}
+ allow_all_users = {{ if hasKey .Values.platform "allowAllUsers" }}{{ .Values.platform.allowAllUsers }}{{ else }}true{{ end }}
+ allowed_users = {{ .Values.platform.allowedUsers | default list | toJson }}
diff --git a/charts/openab-telegram/templates/deployment.yaml b/charts/openab-telegram/templates/deployment.yaml
new file mode 100644
index 000000000..852ee61a0
--- /dev/null
+++ b/charts/openab-telegram/templates/deployment.yaml
@@ -0,0 +1,140 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "openab-telegram.fullname" . }}
+ labels:
+ {{- include "openab-telegram.labels" . | nindent 4 }}
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ {{- include "openab-telegram.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ annotations:
+ checksum/config: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }}
+ checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }}
+ labels:
+ {{- include "openab-telegram.selectorLabels" . | nindent 8 }}
+ spec:
+ {{- with .Values.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ # --- OAB agent (main) ---
+ - name: openab
+ image: {{ include "openab-telegram.agentImage" . | quote }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ {{- with .Values.containerSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ env:
+ - name: HOME
+ value: {{ .Values.agent.workingDir | default "/home/agent" }}
+ {{- range $k, $v := .Values.agent.env }}
+ - name: {{ $k }}
+ value: {{ $v | quote }}
+ {{- end }}
+ {{- range .Values.agent.secretEnv }}
+ - name: {{ .name }}
+ valueFrom:
+ secretKeyRef:
+ name: {{ .secretName }}
+ key: {{ .secretKey }}
+ {{- end }}
+ {{- with .Values.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ volumeMounts:
+ - name: config
+ mountPath: /etc/openab
+ readOnly: true
+ {{- if .Values.persistence.enabled }}
+ - name: data
+ mountPath: {{ .Values.agent.workingDir | default "/home/agent" }}
+ {{- end }}
+ - name: tmp
+ mountPath: /tmp
+
+ # --- Gateway (sidecar) ---
+ - name: gateway
+ image: {{ include "openab-telegram.gatewayImage" . | quote }}
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ {{- with .Values.containerSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ ports:
+ - name: http
+ containerPort: 8080
+ protocol: TCP
+ env:
+ - name: HOME
+ value: {{ .Values.agent.workingDir | default "/home/agent" }}
+ - name: TELEGRAM_BOT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab-telegram.secretName" . }}
+ key: telegram-bot-token
+ volumeMounts:
+ {{- if .Values.persistence.enabled }}
+ - name: data
+ mountPath: {{ .Values.agent.workingDir | default "/home/agent" }}
+ {{- end }}
+ - name: tmp
+ mountPath: /tmp
+
+ # --- Cloudflared tunnel (sidecar) ---
+ - name: cloudflared
+ image: {{ printf "%s:%s" .Values.cloudflared.image .Values.cloudflared.tag }}
+ imagePullPolicy: IfNotPresent
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - ALL
+ args:
+ - tunnel
+ - --no-autoupdate
+ - run
+ - --token
+ - $(TUNNEL_TOKEN)
+ env:
+ - name: TUNNEL_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab-telegram.secretName" . }}
+ key: cloudflare-tunnel-token
+ volumeMounts:
+ - name: tmp
+ mountPath: /tmp
+
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ volumes:
+ - name: config
+ configMap:
+ name: {{ include "openab-telegram.fullname" . }}
+ {{- if .Values.persistence.enabled }}
+ - name: data
+ persistentVolumeClaim:
+ claimName: {{ .Values.persistence.existingClaim | default (include "openab-telegram.fullname" .) }}
+ {{- end }}
+ - name: tmp
+ emptyDir: {}
diff --git a/charts/openab-telegram/templates/pvc.yaml b/charts/openab-telegram/templates/pvc.yaml
new file mode 100644
index 000000000..d332e9596
--- /dev/null
+++ b/charts/openab-telegram/templates/pvc.yaml
@@ -0,0 +1,19 @@
+{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ include "openab-telegram.fullname" . }}
+ annotations:
+ "helm.sh/resource-policy": keep
+ labels:
+ {{- include "openab-telegram.labels" . | nindent 4 }}
+spec:
+ accessModes:
+ - ReadWriteOnce
+ {{- if .Values.persistence.storageClass }}
+ storageClassName: {{ .Values.persistence.storageClass | quote }}
+ {{- end }}
+ resources:
+ requests:
+ storage: {{ .Values.persistence.size | default "1Gi" }}
+{{- end }}
diff --git a/charts/openab-telegram/templates/secret.yaml b/charts/openab-telegram/templates/secret.yaml
new file mode 100644
index 000000000..b50f50445
--- /dev/null
+++ b/charts/openab-telegram/templates/secret.yaml
@@ -0,0 +1,21 @@
+{{- if not .Values.existingSecret }}
+{{- if not .Values.telegramBotToken }}
+{{- fail "telegramBotToken is required when existingSecret is not set (--set telegramBotToken=YOUR_TOKEN)" }}
+{{- end }}
+{{- if not .Values.cloudflareTunnelToken }}
+{{- fail "cloudflareTunnelToken is required when existingSecret is not set (--set cloudflareTunnelToken=YOUR_TOKEN)" }}
+{{- end }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "openab-telegram.fullname" . }}
+ labels:
+ {{- include "openab-telegram.labels" . | nindent 4 }}
+type: Opaque
+stringData:
+ telegram-bot-token: {{ .Values.telegramBotToken | quote }}
+ cloudflare-tunnel-token: {{ .Values.cloudflareTunnelToken | quote }}
+ {{- if .Values.cloudflareApiToken }}
+ cloudflare-api-token: {{ .Values.cloudflareApiToken | quote }}
+ {{- end }}
+{{- end }}
diff --git a/charts/openab-telegram/values.yaml b/charts/openab-telegram/values.yaml
new file mode 100644
index 000000000..2e4399de1
--- /dev/null
+++ b/charts/openab-telegram/values.yaml
@@ -0,0 +1,116 @@
+# openab-telegram values
+#
+# Install:
+# helm install my-bot oci://ghcr.io/openabdev/charts/openab-telegram \
+# --set telegramBotToken="123:ABC" \
+# --set cloudflareTunnelToken="eyJ..." \
+# --set platform.allowedUsers="{YOUR_TG_USER_ID}" \
+# --namespace openab --create-namespace
+#
+# Required:
+# telegramBotToken -- Telegram bot token from @BotFather
+# cloudflareTunnelToken -- Cloudflare Tunnel token
+#
+# Recommended:
+# platform.allowedUsers -- Your Telegram user ID (message @userinfobot to find it)
+#
+# Optional:
+# webhookDomain -- your tunnel domain (for setWebhook reminder in NOTES.txt)
+
+# -- (required unless existingSecret is set) Telegram bot token
+telegramBotToken: ""
+
+# -- (required unless existingSecret is set) Cloudflare Tunnel token
+cloudflareTunnelToken: ""
+
+# -- (optional) Cloudflare API token for automated ingress configuration.
+# Required permissions: Account:Cloudflare Tunnel:Edit
+# If set, post-install NOTES provide a ready-to-run curl command for AI agents.
+cloudflareApiToken: ""
+
+# -- Use a pre-existing K8s Secret instead of creating one from --set values.
+# The Secret must contain keys: telegram-bot-token, cloudflare-tunnel-token
+# See README.md for credential management options.
+existingSecret: ""
+
+# -- Tunnel domain (shown in post-install notes for setWebhook)
+webhookDomain: ""
+
+# -- Release channel: "stable" or "beta"
+# Resolves to the floating image tag of the same name.
+# stable = ghcr.io/openabdev/openab:stable (latest stable release)
+# beta = ghcr.io/openabdev/openab:beta (latest pre-release)
+channel: stable
+
+# -- OAB agent image
+image:
+ repository: ghcr.io/openabdev/openab
+ tag: "" # defaults to appVersion
+ pullPolicy: IfNotPresent
+
+# -- Gateway image
+gateway:
+ image: ghcr.io/openabdev/openab-gateway
+ tag: "v0.5.1"
+
+# -- Cloudflared image
+cloudflared:
+ image: cloudflare/cloudflared
+ tag: "2026.5.0"
+
+# -- Agent configuration
+agent:
+ command: kiro-cli
+ args:
+ - acp
+ - --trust-all-tools
+ workingDir: /home/agent
+ env: {}
+ secretEnv: []
+ # -- Pool settings
+ pool:
+ maxSessions: 10
+ sessionTtlHours: 24
+ # -- Reaction settings
+ reactions:
+ enabled: true
+ removeAfterReply: false
+
+# -- Gateway platform settings
+platform:
+ # allowAllUsers: false = only users listed in allowedUsers can talk to the bot
+ # Set to true to allow any Telegram user (not recommended for production)
+ allowAllUsers: false
+ # Telegram user IDs allowed to interact with the bot.
+ # Find your ID by messaging @userinfobot on Telegram.
+ allowedUsers: []
+ allowAllChannels: true
+ allowedChannels: []
+
+# -- Persistence for agent working directory
+persistence:
+ enabled: true
+ existingClaim: ""
+ storageClass: ""
+ size: 1Gi
+
+# -- Pod-level settings
+resources: {}
+nodeSelector: {}
+tolerations: []
+affinity: {}
+
+podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ runAsGroup: 1000
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+
+containerSecurityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - ALL
diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml
index d21edc249..df8a512a6 100644
--- a/charts/openab/Chart.yaml
+++ b/charts/openab/Chart.yaml
@@ -2,5 +2,5 @@ apiVersion: v2
name: openab
description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI.
type: application
-version: 0.8.3-beta.3
-appVersion: "0.8.3-beta.3"
+version: 0.8.4-beta.5
+appVersion: "0.8.4-beta.5"
diff --git a/charts/openab/README.md b/charts/openab/README.md
new file mode 100644
index 000000000..a7ddae8b1
--- /dev/null
+++ b/charts/openab/README.md
@@ -0,0 +1,101 @@
+# openab Helm Chart
+
+This chart deploys one or more OpenAB agents on Kubernetes.
+
+## Common Values
+
+This page highlights commonly used values and deployment patterns. For the complete list of supported options and defaults, run `helm show values openab/openab` or inspect [`values.yaml`](values.yaml).
+
+### Release naming
+
+| Value | Description | Default |
+|-------|-------------|---------|
+| `nameOverride` | Override the chart name portion used in generated resource names. For per-agent resource names, use `agents..nameOverride`. | `""` |
+| `fullnameOverride` | Override the full generated release name for chart resources. Useful when deploying multiple instances with predictable names. | `""` |
+| `serviceAccountName` | Chart-global ServiceAccount name attached to every agent pod that doesn't define its own. Empty = cluster `default` SA. Per-agent `agents..serviceAccountName` fully overrides this. Chart references an existing SA only — does not create one. Required for workload identity and pod-level RBAC. | `""` |
+| `imagePullSecrets` | Chart-global image pull secrets attached to every agent pod that doesn't define its own. Per-agent `agents..imagePullSecrets` fully overrides this. | `[]` |
+
+### Agent values
+
+Each agent lives under `agents.`.
+
+| Value | Description | Default |
+|-------|-------------|---------|
+| `discord.botToken` | Discord bot token for the agent. | `""` |
+| `discord.allowedChannels` | Channel allowlist. Use `--set-string` for Discord IDs. | `["YOUR_CHANNEL_ID"]` |
+| `discord.allowedUsers` | User allowlist. Empty = allow all users by default. Use `--set-string` for Discord IDs. | `[]` |
+| `discord.allowDm` | Whether the Discord bot responds to direct messages. | `false` |
+| `discord.allowBotMessages` | Controls whether bot messages can trigger replies. | `"off"` |
+| `discord.trustedBotIds` | Optional bot ID allowlist when bot-message replies are enabled. | `[]` |
+| `slack.enabled` | Enable the Slack adapter for the agent. | `false` |
+| `slack.botToken` | Slack Bot User OAuth token. | `""` |
+| `slack.appToken` | Slack App-Level token for Socket Mode. | `""` |
+| `slack.existingSecret` | Name of a pre-existing K8s Secret containing `slack-bot-token` and `slack-app-token`. When set, `botToken`/`appToken` above are ignored and the chart skips creating those keys. Enables External Secrets Operator / Vault / SealedSecrets workflows. | `""` |
+| `slack.allowedChannels` | Slack channel allowlist. Empty means allow all channels by default. | `[]` |
+| `slack.allowedUsers` | Slack user allowlist. Empty means allow all users by default. | `[]` |
+| `nameOverride` | Override this agent's generated resource name. | `""` |
+| `workingDir` | Working directory and HOME inside the container. | `"/home/agent"` |
+| `env` | Inline environment variables passed to the agent process. | `{}` |
+| `envFrom` | Additional environment sources from existing Secrets or ConfigMaps. | `[]` |
+| `pool.maxSessions` | Maximum concurrent ACP sessions for the agent. | `10` |
+| `pool.sessionTtlHours` | Idle session TTL in hours. | `24` |
+| `reactions.enabled` | Enable status reactions. | `true` |
+| `reactions.removeAfterReply` | Remove status reactions after the agent replies. | `false` |
+| `reactions.toolDisplay` | Tool display verbosity: `full`, `compact`, or `none`. | `"full"` |
+| `stt.enabled` | Enable voice-message speech-to-text. | `false` |
+| `stt.apiKey` | API key for the speech-to-text provider. | `""` |
+| `stt.model` | STT model name. | `"whisper-large-v3-turbo"` |
+| `stt.baseUrl` | STT API base URL. | `"https://api.groq.com/openai/v1"` |
+| `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` |
+| `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` |
+| `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` |
+| `cronjobs` | Config-driven scheduled messages for an agent. | `[]` |
+| `persistence.enabled` | Enable persistent storage for auth and settings. | `true` |
+| `persistence.existingClaim` | Reuse an existing PVC instead of creating one. | `""` |
+| `agentsMd` | Contents of `AGENTS.md` mounted into the working directory. | `""` |
+| `serviceAccountName` | Per-agent ServiceAccount name. When set (non-empty), fully overrides chart-global `serviceAccountName`. Useful when only some agents need a dedicated SA. | `""` |
+| `imagePullSecrets` | Per-agent image pull secrets. When set, fully overrides chart-global `imagePullSecrets`. Useful when only some agents pull from a private registry. | `[]` |
+| `extraInitContainers` | Additional init containers for the agent pod. | `[]` |
+| `extraContainers` | Additional sidecar containers for the agent pod. | `[]` |
+| `extraVolumeMounts` | Additional volume mounts for the main agent container. | `[]` |
+| `extraVolumes` | Additional volumes for the agent pod. | `[]` |
+
+## Examples
+
+### Override generated names
+
+```bash
+helm install prod openab/openab \
+ --set fullnameOverride=my-openab \
+ --set-literal agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \
+ --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID'
+```
+
+This makes generated resource names use `my-openab` (for example `my-openab-kiro`) instead of the default `prod-openab`.
+
+### Load credentials with `envFrom`
+
+```yaml
+agents:
+ kiro:
+ envFrom:
+ - secretRef:
+ name: openab-agent-secrets
+ - configMapRef:
+ name: openab-agent-config
+```
+
+This is useful for credentials such as `GH_TOKEN` without storing them directly in Helm values.
+
+### Provide `AGENTS.md` with `--set-file`
+
+```bash
+helm install openab openab/openab \
+ --set-literal agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \
+ --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \
+ --set-file agents.kiro.agentsMd=./AGENTS.md
+```
+
+### Discord ID precision warning
+
+Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision.
diff --git a/charts/openab/templates/NOTES.txt b/charts/openab/templates/NOTES.txt
index 2030ed6fe..c18989a07 100644
--- a/charts/openab/templates/NOTES.txt
+++ b/charts/openab/templates/NOTES.txt
@@ -39,9 +39,17 @@ Agents deployed:
{{- else if eq (toString $cfg.command) "opencode" }}
Authenticate:
kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- opencode auth login
+{{- else if eq (toString $cfg.command) "pi-acp" }}
+ Authenticate:
+ kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- pi
+ (Once inside the interactive interface, type `/login` to authenticate)
{{- else if eq (toString $cfg.command) "cursor-agent" }}
Authenticate:
kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- cursor-agent login
+{{- else if eq (toString $cfg.command) "grok" }}
+ Authenticate:
+ kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- grok login --device-auth
+ For bot/CI deployments, prefer setting `GROK_CODE_XAI_API_KEY` via `secretEnv` instead of interactive device-auth login. See docs/grok.md for all authentication options.
{{- end }}
Restart after auth:
diff --git a/charts/openab/templates/_helpers.tpl b/charts/openab/templates/_helpers.tpl
index 6aa197764..dea2e17bf 100644
--- a/charts/openab/templates/_helpers.tpl
+++ b/charts/openab/templates/_helpers.tpl
@@ -45,6 +45,17 @@ app.kubernetes.io/component: {{ .agent }}
{{- end }}
{{- end }}
+{{/* Secret name to use for Slack credentials.
+ If existingSecret is set, reference it; otherwise fall back to the chart-managed agent secret.
+ Call with: dict "ctx" $ "agent" $name "cfg" $cfg */}}
+{{- define "openab.slackSecretName" -}}
+{{- if and .cfg.slack (.cfg.slack.existingSecret | default "" | trim) -}}
+{{- .cfg.slack.existingSecret | trim -}}
+{{- else -}}
+{{- include "openab.agentFullname" . -}}
+{{- end -}}
+{{- end }}
+
{{/* Resolve image: agent-level string override → global default (repository:tag, tag defaults to appVersion).
Caveat: "contains :" treats registry ports (e.g. my-registry:5000/img) as tagged.
Not an issue for ghcr.io / Docker Hub; revisit if custom registries with ports are needed. */}}
diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml
index 12576a8e0..ebbdb7586 100644
--- a/charts/openab/templates/configmap.yaml
+++ b/charts/openab/templates/configmap.yaml
@@ -44,6 +44,17 @@ data:
{{- if $cfg.discord.trustedBotIds }}
trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }}
{{- end }}
+ {{- range $cfg.discord.allowedRoleIds }}
+ {{- if regexMatch "e\\+|E\\+" (toString .) }}
+ {{- fail (printf "discord.allowedRoleIds contains a mangled ID: %s — use --set-string instead of --set for role IDs" (toString .)) }}
+ {{- end }}
+ {{- if not (regexMatch "^[0-9]{17,20}$" (toString .)) }}
+ {{- fail (printf "discord.allowedRoleIds contains an invalid role ID: %s — must be a 17-20 digit snowflake ID" (toString .)) }}
+ {{- end }}
+ {{- end }}
+ {{- if $cfg.discord.allowedRoleIds }}
+ allowed_role_ids = {{ $cfg.discord.allowedRoleIds | toJson }}
+ {{- end }}
{{- /* allowUserMessages: controls whether the bot requires @mention in threads (Discord) */ -}}
{{- if $cfg.discord.allowUserMessages }}
{{- if not (has $cfg.discord.allowUserMessages (list "involved" "mentions" "multibot-mentions")) }}
@@ -167,6 +178,9 @@ data:
api_key = "${STT_API_KEY}"
model = {{ ($cfg.stt).model | default "whisper-large-v3-turbo" | toJson }}
base_url = {{ ($cfg.stt).baseUrl | default "https://api.groq.com/openai/v1" | toJson }}
+ {{- if hasKey ($cfg.stt | default dict) "echoTranscript" }}
+ echo_transcript = {{ ($cfg.stt).echoTranscript }}
+ {{- end }}
{{- end }}
{{- if ($cfg.gateway).enabled }}
{{- if not ($cfg.gateway).url }}
diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml
index a47a3e8be..5760bb42d 100644
--- a/charts/openab/templates/deployment.yaml
+++ b/charts/openab/templates/deployment.yaml
@@ -29,6 +29,14 @@ spec:
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
+ {{- $svcAcct := default $.Values.serviceAccountName $cfg.serviceAccountName }}
+ {{- if $svcAcct }}
+ serviceAccountName: {{ $svcAcct }}
+ {{- end }}
+ {{- with (default $.Values.imagePullSecrets $cfg.imagePullSecrets) }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
{{- with $cfg.extraInitContainers }}
initContainers:
{{- toYaml . | nindent 8 }}
@@ -49,18 +57,18 @@ spec:
name: {{ include "openab.agentFullname" $d }}
key: discord-bot-token
{{- end }}
- {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }}
+ {{- if and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).existingSecret) }}
- name: SLACK_BOT_TOKEN
valueFrom:
secretKeyRef:
- name: {{ include "openab.agentFullname" $d }}
+ name: {{ include "openab.slackSecretName" $d }}
key: slack-bot-token
{{- end }}
- {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }}
+ {{- if and ($cfg.slack).enabled (or ($cfg.slack).appToken ($cfg.slack).existingSecret) }}
- name: SLACK_APP_TOKEN
valueFrom:
secretKeyRef:
- name: {{ include "openab.agentFullname" $d }}
+ name: {{ include "openab.slackSecretName" $d }}
key: slack-app-token
{{- end }}
{{- if and ($cfg.stt).enabled ($cfg.stt).apiKey }}
diff --git a/charts/openab/templates/gateway-secret.yaml b/charts/openab/templates/gateway-secret.yaml
index 7d4869bdc..3c2c05d00 100644
--- a/charts/openab/templates/gateway-secret.yaml
+++ b/charts/openab/templates/gateway-secret.yaml
@@ -8,7 +8,8 @@
{{- $hasTelegram := (($cfg.gateway).telegram).botToken }}
{{- $hasLine := (($cfg.gateway).line).channelSecret }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken }}
-{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat }}
+{{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }}
+{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat $hasWecom }}
---
apiVersion: v1
kind: Secret
@@ -52,6 +53,11 @@ data:
google-chat-access-token: {{ ($cfg.gateway).googleChat.accessToken | b64enc | quote }}
{{- end }}
{{- end }}
+ {{- if $hasWecom }}
+ wecom-secret: {{ ($cfg.gateway).wecom.secret | b64enc | quote }}
+ wecom-token: {{ ($cfg.gateway).wecom.token | b64enc | quote }}
+ wecom-encoding-aes-key: {{ ($cfg.gateway).wecom.encodingAesKey | b64enc | quote }}
+ {{- end }}
{{- end }}
{{- end }}
{{- end }}
diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml
index 057937dcf..2a89dc79a 100644
--- a/charts/openab/templates/gateway.yaml
+++ b/charts/openab/templates/gateway.yaml
@@ -184,6 +184,40 @@ spec:
value: {{ ($cfg.gateway).googleChat.webhookPath | quote }}
{{- end }}
{{- end }}
+ {{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }}
+ {{- if $hasWecom }}
+ - name: WECOM_CORP_ID
+ value: {{ ($cfg.gateway).wecom.corpId | quote }}
+ - name: WECOM_AGENT_ID
+ value: {{ ($cfg.gateway).wecom.agentId | quote }}
+ - name: WECOM_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: wecom-secret
+ - name: WECOM_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: wecom-token
+ - name: WECOM_ENCODING_AES_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: wecom-encoding-aes-key
+ {{- if (($cfg.gateway).wecom).webhookPath }}
+ - name: WECOM_WEBHOOK_PATH
+ value: {{ ($cfg.gateway).wecom.webhookPath | quote }}
+ {{- end }}
+ {{- if (($cfg.gateway).wecom).streamingEnabled }}
+ - name: WECOM_STREAMING_ENABLED
+ value: {{ ($cfg.gateway).wecom.streamingEnabled | quote }}
+ {{- end }}
+ {{- if (($cfg.gateway).wecom).debounceSecs }}
+ - name: WECOM_DEBOUNCE_SECS
+ value: {{ ($cfg.gateway).wecom.debounceSecs | quote }}
+ {{- end }}
+ {{- end }}
- name: RUST_LOG
value: {{ ($cfg.gateway).rustLog | default "info" | quote }}
livenessProbe:
diff --git a/charts/openab/templates/secret.yaml b/charts/openab/templates/secret.yaml
index 4dc5ba871..b30fac2b9 100644
--- a/charts/openab/templates/secret.yaml
+++ b/charts/openab/templates/secret.yaml
@@ -1,7 +1,7 @@
{{- range $name, $cfg := .Values.agents }}
{{- if ne (include "openab.agentEnabled" $cfg) "false" }}
{{- $hasDiscord := and (ne (toString ($cfg.discord).enabled) "false") ($cfg.discord).botToken }}
-{{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) }}
+{{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) (not ($cfg.slack).existingSecret) }}
{{- $hasStt := and ($cfg.stt).enabled ($cfg.stt).apiKey }}
{{- $hasGateway := and ($cfg.gateway).enabled ($cfg.gateway).token }}
{{- if or $hasDiscord $hasSlack $hasStt $hasGateway }}
@@ -20,10 +20,10 @@ data:
{{- if $hasDiscord }}
discord-bot-token: {{ $cfg.discord.botToken | b64enc | quote }}
{{- end }}
- {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }}
+ {{- if and ($cfg.slack).enabled ($cfg.slack).botToken (not ($cfg.slack).existingSecret) }}
slack-bot-token: {{ $cfg.slack.botToken | b64enc | quote }}
{{- end }}
- {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }}
+ {{- if and ($cfg.slack).enabled ($cfg.slack).appToken (not ($cfg.slack).existingSecret) }}
slack-app-token: {{ $cfg.slack.appToken | b64enc | quote }}
{{- end }}
{{- if $hasStt }}
diff --git a/charts/openab/tests/imagepullsecrets_test.yaml b/charts/openab/tests/imagepullsecrets_test.yaml
new file mode 100644
index 000000000..fc5abedf0
--- /dev/null
+++ b/charts/openab/tests/imagepullsecrets_test.yaml
@@ -0,0 +1,64 @@
+suite: imagePullSecrets support (chart-global + per-agent override)
+templates:
+ - templates/deployment.yaml
+
+tests:
+ - it: does not render imagePullSecrets when neither global nor per-agent is set
+ asserts:
+ - notExists:
+ path: spec.template.spec.imagePullSecrets
+
+ - it: renders chart-global imagePullSecrets when only the global value is set
+ set:
+ imagePullSecrets:
+ - name: regcred
+ asserts:
+ - equal:
+ path: spec.template.spec.imagePullSecrets
+ value:
+ - name: regcred
+
+ - it: renders per-agent imagePullSecrets when only the per-agent value is set
+ set:
+ agents.kiro.imagePullSecrets:
+ - name: kiro-regcred
+ asserts:
+ - equal:
+ path: spec.template.spec.imagePullSecrets
+ value:
+ - name: kiro-regcred
+
+ - it: per-agent imagePullSecrets fully overrides chart-global (no merge)
+ set:
+ imagePullSecrets:
+ - name: global-regcred
+ agents.kiro.imagePullSecrets:
+ - name: kiro-regcred
+ asserts:
+ - equal:
+ path: spec.template.spec.imagePullSecrets
+ value:
+ - name: kiro-regcred
+
+ - it: falls back to chart-global when per-agent imagePullSecrets is an empty list
+ set:
+ imagePullSecrets:
+ - name: global-regcred
+ agents.kiro.imagePullSecrets: []
+ asserts:
+ - equal:
+ path: spec.template.spec.imagePullSecrets
+ value:
+ - name: global-regcred
+
+ - it: supports multiple secrets in the list
+ set:
+ imagePullSecrets:
+ - name: regcred-a
+ - name: regcred-b
+ asserts:
+ - equal:
+ path: spec.template.spec.imagePullSecrets
+ value:
+ - name: regcred-a
+ - name: regcred-b
diff --git a/charts/openab/tests/serviceaccount_test.yaml b/charts/openab/tests/serviceaccount_test.yaml
new file mode 100644
index 000000000..d5b92de43
--- /dev/null
+++ b/charts/openab/tests/serviceaccount_test.yaml
@@ -0,0 +1,51 @@
+suite: serviceAccountName support (chart-global + per-agent override)
+templates:
+ - templates/deployment.yaml
+
+tests:
+ - it: does not render serviceAccountName when neither global nor per-agent is set
+ asserts:
+ - notExists:
+ path: spec.template.spec.serviceAccountName
+
+ - it: renders chart-global serviceAccountName when only the global value is set
+ set:
+ serviceAccountName: "openab"
+ asserts:
+ - equal:
+ path: spec.template.spec.serviceAccountName
+ value: openab
+
+ - it: renders per-agent serviceAccountName when only the per-agent value is set
+ set:
+ agents.kiro.serviceAccountName: "kiro-sa"
+ asserts:
+ - equal:
+ path: spec.template.spec.serviceAccountName
+ value: kiro-sa
+
+ - it: per-agent serviceAccountName fully overrides chart-global
+ set:
+ serviceAccountName: "openab"
+ agents.kiro.serviceAccountName: "kiro-sa"
+ asserts:
+ - equal:
+ path: spec.template.spec.serviceAccountName
+ value: kiro-sa
+
+ - it: empty per-agent serviceAccountName falls back to chart-global
+ set:
+ serviceAccountName: "openab"
+ agents.kiro.serviceAccountName: ""
+ asserts:
+ - equal:
+ path: spec.template.spec.serviceAccountName
+ value: openab
+
+ - it: explicit empty global + empty per-agent renders no serviceAccountName field
+ set:
+ serviceAccountName: ""
+ agents.kiro.serviceAccountName: ""
+ asserts:
+ - notExists:
+ path: spec.template.spec.serviceAccountName
diff --git a/charts/openab/tests/slack-existing-secret_test.yaml b/charts/openab/tests/slack-existing-secret_test.yaml
new file mode 100644
index 000000000..b48a02095
--- /dev/null
+++ b/charts/openab/tests/slack-existing-secret_test.yaml
@@ -0,0 +1,196 @@
+suite: Slack existingSecret support
+templates:
+ - templates/secret.yaml
+ - templates/deployment.yaml
+
+tests:
+ # ── Secret rendering ──────────────────────────────────────────────────────
+
+ - it: omits slack-bot-token and slack-app-token from chart Secret when existingSecret is set (slack-only agent renders no Secret at all)
+ template: templates/secret.yaml
+ set:
+ agents.kiro.discord.enabled: true
+ agents.kiro.discord.botToken: "discord-token"
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.botToken: "xoxb-ignored"
+ agents.kiro.slack.appToken: "xapp-ignored"
+ agents.kiro.slack.existingSecret: "my-slack-creds"
+ asserts:
+ - notExists:
+ path: data["slack-bot-token"]
+ - notExists:
+ path: data["slack-app-token"]
+
+ - it: skips chart Secret entirely for slack-only agent with existingSecret
+ template: templates/secret.yaml
+ set:
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.existingSecret: "my-slack-creds"
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: still creates chart Secret for non-slack tokens when existingSecret is set
+ template: templates/secret.yaml
+ set:
+ agents.kiro.discord.enabled: true
+ agents.kiro.discord.botToken: "discord-token"
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.existingSecret: "my-slack-creds"
+ asserts:
+ - matchRegex:
+ path: data["discord-bot-token"]
+ pattern: '.+'
+ - notExists:
+ path: data["slack-bot-token"]
+ - notExists:
+ path: data["slack-app-token"]
+
+ - it: renders chart-managed Slack secret keys when existingSecret is unset
+ template: templates/secret.yaml
+ set:
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.botToken: "xoxb-test"
+ agents.kiro.slack.appToken: "xapp-test"
+ asserts:
+ - matchRegex:
+ path: data["slack-bot-token"]
+ pattern: '.+'
+ - matchRegex:
+ path: data["slack-app-token"]
+ pattern: '.+'
+
+ # ── Deployment env-var rendering ──────────────────────────────────────────
+
+ - it: references existingSecret name in SLACK_BOT_TOKEN secretKeyRef
+ template: templates/deployment.yaml
+ set:
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.existingSecret: "my-slack-creds"
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_BOT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: my-slack-creds
+ key: slack-bot-token
+
+ - it: references existingSecret name in SLACK_APP_TOKEN secretKeyRef
+ template: templates/deployment.yaml
+ set:
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.existingSecret: "my-slack-creds"
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_APP_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: my-slack-creds
+ key: slack-app-token
+
+ - it: falls back to chart-managed secret name when existingSecret is unset
+ template: templates/deployment.yaml
+ set:
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.botToken: "xoxb-test"
+ agents.kiro.slack.appToken: "xapp-test"
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_BOT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-openab-kiro
+ key: slack-bot-token
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_APP_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-openab-kiro
+ key: slack-app-token
+
+ - it: omits SLACK env vars when slack is disabled even if existingSecret is set
+ template: templates/deployment.yaml
+ set:
+ agents.kiro.slack.enabled: false
+ agents.kiro.slack.existingSecret: "my-slack-creds"
+ asserts:
+ - notContains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_BOT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: my-slack-creds
+ key: slack-bot-token
+
+ # ── Mixed-adapter deployment ──────────────────────────────────────────────
+
+ - it: renders Discord from chart-managed Secret and Slack from existingSecret in same Deployment
+ template: templates/deployment.yaml
+ set:
+ agents.kiro.discord.enabled: true
+ agents.kiro.discord.botToken: "disc-token"
+ agents.kiro.slack.enabled: true
+ agents.kiro.slack.existingSecret: "my-slack-creds"
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: DISCORD_BOT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-openab-kiro
+ key: discord-bot-token
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_BOT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: my-slack-creds
+ key: slack-bot-token
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_APP_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: my-slack-creds
+ key: slack-app-token
+
+ # ── Multi-agent scoping ───────────────────────────────────────────────────
+
+ - it: agent with existingSecret does not affect another agent using inline tokens
+ template: templates/deployment.yaml
+ documentIndex: 1
+ set:
+ agents.alpha.slack.enabled: true
+ agents.alpha.slack.existingSecret: "alpha-ext-creds"
+ agents.beta.slack.enabled: true
+ agents.beta.slack.botToken: "xoxb-beta"
+ agents.beta.slack.appToken: "xapp-beta"
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_BOT_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-openab-beta
+ key: slack-bot-token
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SLACK_APP_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-openab-beta
+ key: slack-app-token
diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml
index 4314d2df2..049e7df35 100644
--- a/charts/openab/values.yaml
+++ b/charts/openab/values.yaml
@@ -4,6 +4,30 @@ image:
tag: ""
pullPolicy: IfNotPresent
+# Chart-level name override used in generated resource names.
+# For per-agent overrides, use agents..nameOverride.
+nameOverride: ""
+
+# Override the full release name used in generated resource names.
+fullnameOverride: ""
+
+# Chart-global ServiceAccount name for agent pods, used when an agent doesn't
+# set its own `serviceAccountName`. Empty string = use cluster default SA.
+# Per-agent values (agents..serviceAccountName) take precedence — when
+# set, they fully override this. The chart only references an existing SA; it
+# does NOT create one or manage annotations (provision out-of-band).
+# Example:
+# serviceAccountName: "openab"
+serviceAccountName: ""
+
+# Chart-global image pull secrets, used when an agent doesn't set its own
+# `imagePullSecrets`. Per-agent values (agents..imagePullSecrets) take
+# precedence — when set, they fully override (do not merge with) this list.
+# Example:
+# imagePullSecrets:
+# - name: regcred
+imagePullSecrets: []
+
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
@@ -37,6 +61,8 @@ agents:
# allowBotMessages: "off"
# # trustedBotIds: [] # empty = any bot (mode permitting)
# trustedBotIds: []
+ # # allowedRoleIds: [] # role IDs that trigger the bot
+ # allowedRoleIds: []
# workingDir: /home/agent
# # nameOverride: custom deployment name (default: -)
# nameOverride: ""
@@ -100,6 +126,28 @@ agents:
# agentsMd: ""
# resources: {}
# image: "ghcr.io/openabdev/openab-opencode:latest"
+ # pi:
+ # command: pi-acp
+ # discord:
+ # enabled: true
+ # allowedChannels:
+ # - "YOUR_CHANNEL_ID"
+ # allowedUsers: []
+ # workingDir: /home/node
+ # env: {}
+ # envFrom: []
+ # secretEnv: []
+ # pool:
+ # maxSessions: 10
+ # sessionTtlHours: 24
+ # reactions:
+ # enabled: true
+ # removeAfterReply: false
+ # persistence:
+ # enabled: true
+ # storageClass: ""
+ # size: 1Gi
+ # image: "ghcr.io/openabdev/openab-pi:latest"
# cursor:
# command: cursor-agent
# args:
@@ -115,6 +163,7 @@ agents:
# allowedUsers: []
# workingDir: /home/agent
# env: {}
+ # # Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN.
# envFrom: []
# secretEnv: []
# pool:
@@ -130,6 +179,55 @@ agents:
# storageClass: ""
# size: 1Gi
# image: "ghcr.io/openabdev/openab-cursor:latest"
+ # hermes:
+ # command: hermes-acp
+ # discord:
+ # enabled: true
+ # allowedChannels:
+ # - "YOUR_CHANNEL_ID"
+ # allowedUsers: []
+ # allowBotMessages: "off"
+ # trustedBotIds: []
+ # workingDir: /home/agent
+ # env: {}
+ # envFrom: []
+ # secretEnv: []
+ # pool:
+ # maxSessions: 10
+ # sessionTtlHours: 24
+ # persistence:
+ # enabled: true
+ # storageClass: ""
+ # size: 1Gi
+ # image: "ghcr.io/openabdev/openab-hermes"
+ # grok:
+ # command: grok
+ # args:
+ # - agent
+ # - stdio
+ # # See docs/grok.md for the three authentication methods:
+ # # 1. API key (recommended for bots): set GROK_CODE_XAI_API_KEY via env or secretEnv
+ # # 2. Device-code OAuth: kubectl exec + `grok login --device-auth` (requires persistence)
+ # # 3. Enterprise deployment key: GROK_DEPLOYMENT_KEY
+ # discord:
+ # enabled: true
+ # allowedChannels:
+ # - "YOUR_CHANNEL_ID"
+ # allowedUsers: []
+ # allowBotMessages: "off"
+ # trustedBotIds: []
+ # workingDir: /home/agent
+ # env: {}
+ # envFrom: []
+ # secretEnv: []
+ # pool:
+ # maxSessions: 10
+ # sessionTtlHours: 24
+ # persistence:
+ # enabled: true
+ # storageClass: ""
+ # size: 1Gi
+ # image: "ghcr.io/openabdev/openab-grok"
image: ""
command: kiro-cli
args:
@@ -155,6 +253,11 @@ agents:
allowBotMessages: "off"
# trustedBotIds: [] # empty = any bot (mode permitting); set to restrict
trustedBotIds: []
+ # allowedRoleIds: Role IDs that trigger the bot (same as direct @mention).
+ # Create a Discord role, assign it to the bot, then users can @role to trigger.
+ # Empty (default) = role mentions do not trigger the bot.
+ # allowedRoleIds: ["1234567890123456789"]
+ allowedRoleIds: []
# maxBotTurns: soft cap on consecutive bot turns per thread before
# the bot stops auto-replying. A human message resets the counter.
# Default 100 (Rust-side `default_max_bot_turns()`). Raise for long
@@ -174,6 +277,14 @@ agents:
enabled: false
botToken: "" # Bot User OAuth Token (xoxb-...)
appToken: "" # App-Level Token (xapp-...) for Socket Mode
+ # Use a pre-existing K8s Secret for slack-bot-token and slack-app-token
+ # instead of having the chart create one from botToken/appToken.
+ # Set to the Secret name; the Secret must contain keys:
+ # slack-bot-token → Bot User OAuth Token (xoxb-...)
+ # slack-app-token → App-Level Token (xapp-...)
+ # When set, botToken and appToken above are ignored. Recommended path for
+ # External Secrets Operator / Vault / SealedSecrets deployments.
+ existingSecret: ""
# allowAllChannels/allowAllUsers: same auto-infer logic as discord
allowedChannels: [] # empty + no allowAllChannels → allow all (auto-inferred)
allowedUsers: [] # empty + no allowAllUsers → allow all (auto-inferred)
@@ -205,6 +316,7 @@ agents:
# maxBatchTokens: 24000
workingDir: /home/agent
env: {}
+ # Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN.
envFrom: []
secretEnv: [] # list of {name, secretName, secretKey} — rendered as valueFrom.secretKeyRef; keys auto-added to inherit_env
pool:
@@ -222,6 +334,9 @@ agents:
apiKey: ""
model: "whisper-large-v3-turbo"
baseUrl: "https://api.groq.com/openai/v1"
+ # Echo the transcribed text back to the thread before the agent reply
+ # so users can verify STT accuracy. Default: false (opt-in).
+ echoTranscript: false
gateway:
enabled: false # set to true + provide url to enable the [gateway] config block
deploy: true # set to false to skip Gateway Deployment/Service (config-only mode)
@@ -290,6 +405,17 @@ agents:
saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh)
accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL)
webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH
+ # WeCom (企业微信) adapter config (gateway-side env vars)
+ # See docs/wecom.md for full setup guide
+ wecom:
+ corpId: "" # Enterprise Corp ID → WECOM_CORP_ID
+ secret: "" # App Secret → WECOM_SECRET (use --set-literal or external secret mgmt)
+ token: "" # Callback verification token → WECOM_TOKEN
+ encodingAesKey: "" # 43-char AES key → WECOM_ENCODING_AES_KEY
+ agentId: "" # Agent ID → WECOM_AGENT_ID (required)
+ webhookPath: "" # Gateway default: /webhook/wecom → WECOM_WEBHOOK_PATH
+ streamingEnabled: "" # Enable thinking-placeholder + recall streaming (causes brief client flicker) → WECOM_STREAMING_ENABLED. Default off.
+ debounceSecs: "" # Debounce quiet-period seconds before flushing streamed text → WECOM_DEBOUNCE_SECS. Default 3, minimum 1 (0 is treated as unset by Helm).
# Scheduled messages — config-driven cron (ADR: basic-cronjob)
# Each entry sends a message to the agent at the specified schedule.
# Example:
@@ -311,6 +437,7 @@ agents:
existingClaim: "" # set to reuse an existing PVC (skips PVC creation)
storageClass: ""
size: 1Gi # defaults to 1Gi if not set
+ # Mount a custom AGENTS.md file. Useful with --set-file.
# ⚠️ When set, this ConfigMap mount shadows any file at the same path on the PVC.
# The PVC file is NOT deleted but becomes invisible to the agent. Remove agentsMd to restore.
agentsMd: ""
@@ -318,6 +445,17 @@ agents:
nodeSelector: {}
tolerations: []
affinity: {}
+ # Per-agent ServiceAccount name. When set (non-empty), overrides the
+ # chart-global `serviceAccountName` for this agent only. Useful in
+ # multi-agent deployments where only some agents need a dedicated SA.
+ # serviceAccountName: "openab"
+ serviceAccountName: ""
+ # Per-agent image pull secrets. When set, overrides the chart-global
+ # `imagePullSecrets` for this agent only. Useful in multi-agent deployments
+ # where only some agents pull from a private registry.
+ # imagePullSecrets:
+ # - name: regcred
+ imagePullSecrets: []
# extraInitContainers adds init containers to the pod (runs before the main container)
extraInitContainers: []
# extraContainers adds sidecar containers to the pod
diff --git a/config.toml.example b/config.toml.example
index cf99f6bdb..17f305a67 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -11,6 +11,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
# allow_bot_messages = "off" # "off" (default) | "mentions" | "all"
# "mentions" is recommended for multi-agent collaboration
# trusted_bot_ids = [] # empty = any bot (mode permitting); set to restrict
+# allowed_role_ids = [] # role IDs that trigger the bot (same as direct @mention)
+ # note: if multiple bots share the same role, all will respond simultaneously
# allow_user_messages = "involved" # "involved" (default) | "mentions"
# "involved" = reply in threads bot owns or has participated in
# "mentions" = always require @mention
@@ -51,14 +53,16 @@ args = ["acp", "--trust-all-tools"]
working_dir = "/home/agent"
# [agent]
-# command = "claude"
-# args = ["--acp"]
+# command = "claude-agent-acp"
+# args = []
# working_dir = "/home/node"
+# # Auth: kubectl exec -it deploy/openab-claude -- claude auth login
+# # (credentials persist in HOME PVC across restarts; see docs/claude-code.md)
# ⚠️ SECURITY WARNING: Any env var listed here is accessible to the agent.
# A user could trick the agent into leaking these values via prompt injection.
# All supported backends support OAuth login — prefer that over env var API keys.
# Note: env vars here can override baseline vars (HOME, PATH, USER) if needed.
-# env = { ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" }
+# env = {}
#
# By default, the agent subprocess only inherits these baseline vars:
# Linux/macOS: HOME, PATH, USER
@@ -101,9 +105,30 @@ working_dir = "/home/agent"
# working_dir = "/home/agent"
# env = {} # Auth via: kubectl exec -it -- cursor-agent login
+# [agent]
+# command = "hermes-acp"
+# working_dir = "/home/agent"
+# # Auth via: kubectl exec -it -- hermes auth add xai-oauth
+# # Supports 30+ providers (xAI Grok OAuth, Anthropic, OpenAI Codex, Gemini, etc.)
+# # Provider switching: kubectl exec -it -- hermes model
+
+# [agent]
+# command = "grok"
+# args = ["agent", "stdio"]
+# working_dir = "/home/agent"
+# # Auth options:
+# # 1. API key: env = { GROK_CODE_XAI_API_KEY = "${XAI_API_KEY}" }
+# # 2. Device-code: kubectl exec -it -- grok login --device-auth
+# # 3. Deployment key: env = { GROK_DEPLOYMENT_KEY = "${GROK_DEPLOYMENT_KEY}" }
+# # See docs/grok.md for details.
+
[pool]
max_sessions = 10
session_ttl_hours = 24
+# Hard ceiling (sec) per prompt; see #732. Default: 1800 (30 min).
+# prompt_hard_timeout_secs = 1800
+# Liveness-check cadence (sec) for the recv loop; see #732. Default: 30.
+# liveness_check_secs = 30
[markdown]
tables = "code" # "code" (default) | "bullets" | "off"
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index ba7984193..386815b94 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -18,7 +18,17 @@ fi
cat > "$CONFIG" << EOF
[discord]
bot_token = "${DISCORD_BOT_TOKEN}"
+EOF
+
+# Only set allowed_channels if DISCORD_CHANNEL_ID is non-empty
+# (empty = allow all channels; [""] would cause a parse error)
+if [ -n "$DISCORD_CHANNEL_ID" ]; then
+ cat >> "$CONFIG" << EOF
allowed_channels = ["${DISCORD_CHANNEL_ID}"]
+EOF
+fi
+
+cat >> "$CONFIG" << EOF
[agent]
command = "kiro-cli"
diff --git a/docs/adr/ecs-control-plane.md b/docs/adr/ecs-control-plane.md
new file mode 100644
index 000000000..191487e8b
--- /dev/null
+++ b/docs/adr/ecs-control-plane.md
@@ -0,0 +1,689 @@
+# ADR: ECS Control Plane (CRD + Operator Pattern on ECS)
+
+- **Status:** Proposed
+- **Date:** 2026-05-18
+- **Author:** @pahud.hsieh
+- **Related:** [Multi-Platform Adapters](./multi-platform-adapters.md), [Basic CronJob](./basic-cronjob.md)
+
+---
+
+## 1. Context & Motivation
+
+OpenAB currently deploys on Kubernetes using Helm charts. While K8s provides a mature operator pattern (CRD + Controller), many teams prefer or require **Amazon ECS** for its operational simplicity and tighter AWS integration.
+
+We want to bring the same declarative, self-healing deployment model to ECS:
+
+- Operators declare desired state in YAML manifests (analogous to CRDs)
+- A controller reconciles desired state against actual ECS resources
+- OAB instances + arbitrary backends are deployed and maintained automatically
+
+This enables a "GitOps for ECS" workflow where pushing a YAML change triggers the controller to converge the cluster to the new desired state.
+
+---
+
+## 2. Design Overview
+
+```
+┌──────────────────────────────────────────────────────┐
+│ ECS Control Plane (runs as ECS Service) │
+│ │
+│ ┌────────────┐ ┌──────────────┐ ┌─────────────┐ │
+│ │ State Store│ │ Reconciler │ │ ECS API / │ │
+│ │ (S3) │◄─│ Controller │─►│ CloudMap │ │
+│ │ │ │ │ │ │ │
+│ └────────────┘ └──────────────┘ └─────────────┘ │
+│ ▲ │
+│ │ events / poll │
+│ ▼ │
+│ ┌──────────────┐ │
+│ │ S3 Events / │ │
+│ │ EventBridge │ │
+│ └──────────────┘ │
+└──────────────────────────────────────────────────────┘
+```
+
+### Core Loop (Reconciliation)
+
+1. Load all YAML manifests from S3 (desired state)
+2. Describe current ECS services/tasks (observed state)
+3. Compute diff (compare `metadata.generation` vs `status.observedGeneration`)
+4. Apply changes: create, update, or delete ECS resources
+5. Write status back to S3 (separate prefix), set `status.observedGeneration = metadata.generation`
+6. Sleep / wait for next event
+
+---
+
+## 3. Manifest Schema
+
+### API Identity (fixed)
+
+| Field | Value |
+|-------|-------|
+| `apiVersion` | `oab.dev/v1` |
+| `kind` | `OABService` |
+
+All examples in this ADR use this identity. No other combinations (`openab.dev/v1`, `AgentDeployment`) are valid.
+
+### Full Example
+
+```yaml
+apiVersion: oab.dev/v1
+kind: OABService
+metadata:
+ name: my-agent
+ namespace: prod
+ generation: 4 # incremented by oabctl on each apply
+spec:
+ replicas: 1
+ capacityProvider: FARGATE # FARGATE (default) or FARGATE_SPOT
+ cpu: 256 # vCPU units (256 = 0.25 vCPU)
+ memory: 512 # MB
+ taskDefinition:
+ image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openab:latest
+ bootstrapFrom: s3://oab-state/agents/my-agent/latest.tar.gz
+ secrets:
+ - name: DISCORD_BOT_TOKEN
+ source: ssm
+ path: /oab/my-agent/discord-token
+ - name: LLM_API_KEY
+ source: secretsmanager
+ arn: arn:aws:secretsmanager:us-east-1:123:secret:oab/my-agent/llm-key
+ networking:
+ subnets: [subnet-abc, subnet-def]
+ securityGroups: [sg-123]
+ assignPublicIp: false
+ config:
+ agent:
+ name: my-agent
+ backend: bedrock
+ model: us.anthropic.claude-sonnet-4-20250514
+ discord:
+ enabled: true
+ botId: "123456789"
+ guildId: "987654321"
+ channelIds: ["111111111"]
+ steering:
+ source: s3
+ bucket: oab-steering
+ prefix: agents/my-agent/
+ memory:
+ backend: s3
+ bucket: oab-memory
+ prefix: agents/my-agent/
+ tools:
+ github: { enabled: true }
+ web: { enabled: true }
+status:
+ phase: Running # Pending | Running | Failed | Terminating
+ observedGeneration: 4 # last generation the controller reconciled
+ taskArns:
+ - arn:aws:ecs:us-east-1:123456789012:task/cluster/abc123
+ lastReconciled: "2026-05-18T22:50:00Z"
+ conditions:
+ - type: Available
+ status: "True"
+ lastTransitionTime: "2026-05-18T22:50:00Z"
+```
+
+### Key Fields
+
+| Field | Description |
+|-------|-------------|
+| `metadata.generation` | Monotonically increasing counter, bumped by `oabctl apply` |
+| `spec.capacityProvider` | `FARGATE` (on-demand) or `FARGATE_SPOT` (up to 70% savings, tolerates interruption) |
+| `spec.cpu` / `spec.memory` | Maps to ECS task definition (must be valid Fargate combination) |
+| `spec.taskDefinition.image` | Container image |
+| `spec.bootstrapFrom` | S3 path to mutable state archive (memory, knowledge base — **no secrets**) |
+| `spec.secrets` | Per-agent secret references (SSM / Secrets Manager) |
+| `spec.config` | Structured agent config; controller renders to `config.toml` |
+| `spec.networking` | ECS awsvpc configuration |
+| `status.observedGeneration` | Last generation the controller successfully reconciled |
+
+### Replicas Semantics
+
+OAB agents are **single-instance** by design — each agent holds one adapter connection (WebSocket gateway for Discord/Telegram/Slack). There is no load balancing across agent replicas.
+
+**Rules:**
+- `replicas: 1` — the only valid value
+- Controller **rejects** `replicas > 1` at validation time
+- Scaling is horizontal by deploying **more agents** (each with its own bot token), not by replicating one agent
+
+### Fleet Provisioning (`OABFleet`)
+
+Enterprise scenario: provision 10-20 agents in one apply. Controller handles everything including Discord Bot registration.
+
+```yaml
+apiVersion: oab.dev/v1
+kind: OABFleet
+metadata:
+ name: enterprise-team
+ namespace: prod
+spec:
+ defaults:
+ capacityProvider: FARGATE_SPOT
+ cpu: 512
+ memory: 1024
+ taskDefinition:
+ image: ghcr.io/openabdev/openab:latest
+ networking:
+ subnets: [subnet-abc, subnet-def]
+ securityGroups: [sg-oab]
+ discord:
+ enabled: true # all agents use Discord
+ agents:
+ - name: kiro-01
+ config: { agent: { backend: kiro } }
+ - name: kiro-02
+ config: { agent: { backend: kiro } }
+ - name: kiro-03
+ config: { agent: { backend: kiro } }
+ - name: codex-01
+ config: { agent: { backend: codex } }
+ cpu: 1024
+ memory: 2048 # override defaults
+ - name: codex-02
+ config: { agent: { backend: codex } }
+ cpu: 1024
+ memory: 2048
+ - name: gemini-01
+ config: { agent: { backend: gemini } }
+ - name: gemini-02
+ config: { agent: { backend: gemini } }
+ - name: gemini-03
+ config: { agent: { backend: gemini } }
+ - name: gemini-04
+ config: { agent: { backend: gemini } }
+ - name: gemini-05
+ config: { agent: { backend: gemini } }
+```
+
+### Discord Bot Provisioning Flow
+
+Since Discord does not offer a public API for Bot creation, the actual flow for fleet provisioning is:
+
+```
+Pre-requisite (manual, one-time per agent):
+ → Create Bot in Discord Developer Portal
+ → Store token in SSM: /oab/{namespace}/{name}/discord-token
+ → Note the OAuth2 invite URL
+
+oabctl apply -f fleet.yaml
+ │
+ │ For each agent:
+ ├─ 1. Validate spec + verify secret exists in SSM
+ ├─ 2. Render config.toml → S3 artifact (immutable, per generation)
+ ├─ 3. Register ECS TaskDefinition (pinned to config artifact + secrets)
+ ├─ 4. Create ECS Service (desiredCount=1)
+ └─ 5. Write status (phase=Running)
+```
+
+**Apply output:**
+
+```bash
+$ oabctl apply -f fleet.yaml
+
+✓ kiro-01 provisioned (ECS service created, task running)
+✓ kiro-02 provisioned (ECS service created, task running)
+✓ kiro-03 provisioned (ECS service created, task running)
+✓ codex-01 provisioned (ECS service created, task running)
+✓ codex-02 provisioned (ECS service created, task running)
+✓ gemini-01 provisioned (ECS service created, task running)
+...
+
+10 agents provisioned.
+```
+
+**User's manual steps (one-time):** create bots in Discord Developer Portal, store tokens, add bots to server via OAuth URL.
+
+### Responsibility Model
+
+| Layer | Responsibility |
+|-------|---------------|
+| `oabctl` / Controller | Desired state: create ECS Services; **observe** ECS task/service status → write back to `status/` |
+| ECS | Runtime health: task dies → auto-restart (desiredCount=1) |
+| User | One-time: create bots in Discord Developer Portal, add to server via OAuth URL |
+
+The controller does **not** restart tasks — ECS handles that. But the controller **does** observe ECS service/task/deployment status on each reconcile cycle and writes it back to `status/{ns}/{name}.json`. This enables `oabctl get`, `oabctl wait --for=Available`, and `status.phase` / `status.conditions` to work.
+
+### Prerequisites for Auto-Registration
+
+> **⚠️ Note:** Discord does not provide a public API to programmatically create Bot Applications. `autoRegister` is a **future research item** pending Discord API changes or partnership access. For now, bot credentials must be pre-created manually in the Discord Developer Portal.
+
+**Phase 1/2 approach:** Each agent's bot token is pre-created and stored in SSM/Secrets Manager. The `OABFleet` spec references existing credentials:
+
+```yaml
+spec:
+ agents:
+ - name: kiro-01
+ config: { agent: { backend: kiro } }
+ secrets:
+ - name: DISCORD_BOT_TOKEN
+ source: ssm
+ path: /oab/kiro-01/discord-token # pre-created
+```
+
+**Future (if Discord API allows):** `autoRegister: true` would automate Bot creation. This requires a separate research ADR.
+
+---
+
+## 4. Multi-Runtime Support (ECS + K8s)
+
+The same YAML manifest can deploy to **both ECS and Kubernetes**. The spec is platform-agnostic; platform-specific details live in an optional `platform:` overlay.
+
+### Design Principle
+
+```
+┌─────────────────────┐
+│ oab.dev/v1 YAML │ ← one spec, platform-agnostic core
+└──────────┬──────────┘
+ │
+ ┌──────┴──────┐
+ ▼ ▼
+┌────────┐ ┌────────┐
+│ ECS │ │ K8s │
+│Controller│ │Operator│
+└────┬───┘ └────┬───┘
+ ▼ ▼
+ ECS Service Deployment + ConfigMap + ExternalSecret
+```
+
+Each controller reads the core spec and its own `platform:` overlay, ignoring the other.
+
+### Spec with Platform Overlay
+
+```yaml
+apiVersion: oab.dev/v1
+kind: OABService
+metadata:
+ name: chaodu
+ namespace: prod
+spec:
+ # Core (cross-platform)
+ cpu: 512
+ memory: 1024
+ config:
+ agent: { backend: kiro }
+ discord: { enabled: true, botId: "123" }
+ secrets:
+ - name: DISCORD_BOT_TOKEN
+ source: ssm
+ path: /oab/chaodu/discord-token
+
+ # Platform-specific (each controller reads only its own key)
+ platform:
+ ecs:
+ capacityProvider: FARGATE_SPOT
+ executionRole: arn:aws:iam::123456789012:role/oab-task-execution
+ taskRole: arn:aws:iam::123456789012:role/oab-chaodu-task
+ networking:
+ subnets: [subnet-abc, subnet-def]
+ securityGroups: [sg-oab]
+ assignPublicIp: false
+ k8s:
+ nodeSelector: { node.kubernetes.io/capacity-type: spot }
+ serviceAccount: oab-agent
+ storageClass: gp3
+```
+
+### Translation Table
+
+| Core Spec | ECS Controller | K8s Operator |
+|-----------|---------------|--------------|
+| `cpu: 512` | TaskDef `cpu=512` | `resources.requests.cpu: 500m` |
+| `memory: 1024` | TaskDef `memory=1024` | `resources.requests.memory: 1Gi` |
+| `spec.config` | Render → S3 artifact → startup wrapper | Render → ConfigMap → volume mount |
+| `spec.secrets[].source: ssm` | ECS native `secrets` field | ExternalSecret → K8s Secret |
+| `platform.ecs.capacityProvider` | Fargate capacity provider | _(ignored)_ |
+| `platform.ecs.executionRole` | ECS task execution role | _(ignored)_ |
+| `platform.ecs.taskRole` | ECS task role | _(ignored)_ |
+| `platform.k8s.nodeSelector` | _(ignored)_ | Pod nodeSelector |
+
+### Rules
+
+- `platform:` is optional. If omitted, controller uses its own defaults.
+- Controller **strict-validates** its own platform key (e.g., ECS controller rejects invalid `platform.ecs.*` fields with an error).
+- Controller **ignores** other platform keys entirely (ECS controller skips `platform.k8s`, and vice versa).
+- Core spec fields (`cpu`, `memory`, `config`, `secrets`) are mandatory and cross-platform.
+- `OABFleet` also supports `platform:` at both `defaults` and per-agent level.
+
+### Phase Plan
+
+- **Phase 1**: ECS controller only. `platform.ecs` supported, `platform.k8s` ignored.
+- **Phase 3**: K8s operator reads same manifests (from S3 or as native CRD). Shared schema, different runtime.
+
+---
+
+## 5. Config Delivery Model
+
+The controller does **not** mount config into containers (ECS/Fargate has no shared volume equivalent to K8s ConfigMap). Instead:
+
+### Flow
+
+```
+oabctl apply -f agent.yaml
+ → writes manifest to S3 (manifests/{ns}/{name}.yaml, generation incremented)
+
+Controller reconcile:
+ → reads spec.config from manifest
+ → renders config.toml
+ → writes to s3://oab-control-plane/artifacts/{ns}/{name}/{generation}/config.toml (immutable)
+ → registers new ECS TaskDefinition with env CONFIG_ARTIFACT_PATH pinned to this generation
+ → updates ECS Service (rolling deployment)
+
+ECS Task startup (entrypoint wrapper):
+ → s3:GetObject ${CONFIG_ARTIFACT_PATH} → /home/agent/config.toml
+ → s3:GetObject ${BOOTSTRAP_FROM} → tar xzf → /home/agent/ (mutable state only)
+ → exec openab
+```
+
+Config artifacts are **immutable per generation** — once written, never overwritten. During rolling updates, old tasks keep fetching their pinned generation while new tasks use the new one.
+
+### Entrypoint Wrapper
+
+```bash
+#!/bin/bash
+set -e
+# 1. Restore mutable state FIRST (memory, knowledge base)
+if [ -n "$BOOTSTRAP_FROM" ]; then
+ aws s3 cp "$BOOTSTRAP_FROM" /tmp/bootstrap.tar.gz
+ tar xzf /tmp/bootstrap.tar.gz -C /home/agent/
+ rm /tmp/bootstrap.tar.gz
+fi
+# 2. Download controller-rendered config LAST (overwrites any config.toml from archive)
+aws s3 cp "$CONFIG_ARTIFACT_PATH" /home/agent/config.toml
+exec /usr/local/bin/openab
+```
+
+**Order matters:** bootstrap archive is restored first, then the controller-rendered `config.toml` overwrites any stale config from the archive. `oabctl snapshot` must exclude `config.toml` from the archive (it's controller-managed, not user state).
+
+### What Goes Where
+
+| Content | Location | Managed By |
+|---------|----------|------------|
+| `config.toml` | S3 artifact (controller renders) | `spec.config` in manifest |
+| Secrets (bot tokens, API keys) | SSM / Secrets Manager | `spec.secrets` in manifest |
+| Memory / knowledge base | `bootstrapFrom` archive | `oabctl snapshot` |
+| Steering files | S3 (referenced in config.toml) | Separate steering bucket |
+
+**Secrets never go in the bootstrap archive.** The archive contains only mutable runtime state that the agent accumulates over time.
+
+### IAM Requirements (Task Execution Role)
+
+The ECS task execution role (`platform.ecs.executionRole`) must have:
+
+```json
+{
+ "Effect": "Allow",
+ "Action": ["s3:GetObject"],
+ "Resource": [
+ "arn:aws:s3:::oab-control-plane/artifacts/${namespace}/${name}/*",
+ "arn:aws:s3:::oab-state/agents/${name}/*"
+ ]
+}
+```
+
+This allows the startup wrapper to download the controller-rendered `config.toml` and the bootstrap archive.
+
+---
+
+## 6. Per-Agent Secret Injection
+
+Each agent/bot has its **own** credentials — no token sharing between agents.
+
+### Design Principles
+
+- Each `OABService` owns its secrets (1:1 mapping)
+- Controller never touches secret values — it only wires references into ECS Task Definitions
+- ECS native `secrets` field handles injection at runtime
+- IAM scoping ensures each task role can only read its own secret path
+
+### Spec
+
+```yaml
+spec:
+ secrets:
+ - name: DISCORD_BOT_TOKEN
+ source: ssm
+ path: /oab/chaodu/discord-token
+ - name: LLM_API_KEY
+ source: secretsmanager
+ arn: arn:aws:secretsmanager:us-east-1:123:secret:oab/chaodu/llm-key
+```
+
+### Controller Behavior
+
+1. **Deploy** — maps `spec.secrets` to ECS TaskDefinition `secrets` field:
+ ```json
+ {
+ "secrets": [
+ { "name": "DISCORD_BOT_TOKEN", "valueFrom": "/oab/chaodu/discord-token" },
+ { "name": "LLM_API_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:123:secret:oab/chaodu/llm-key" }
+ ]
+ }
+ ```
+2. **IAM** — task execution role scoped to the agent's secret path:
+ ```json
+ {
+ "Effect": "Allow",
+ "Action": ["ssm:GetParameters", "secretsmanager:GetSecretValue"],
+ "Resource": [
+ "arn:aws:ssm:*:*:parameter/oab/chaodu/*",
+ "arn:aws:secretsmanager:*:*:secret:oab/chaodu/*"
+ ]
+ }
+ ```
+
+### Secret Rotation Lifecycle
+
+```
+1. Operator rotates secret in SSM/Secrets Manager (manual or auto-rotation)
+2. Controller detects rotation:
+ - Option A: spec.secrets[].autoRestart: true → controller forces new deployment
+ - Option B: operator runs `oabctl restart my-agent`
+3. ECS launches new task → new task fetches fresh secret value at startup
+4. Old task drains and stops (ECS rolling update)
+5. Controller updates status:
+ - conditions[].type: SecretsRefreshed
+ - conditions[].lastTransitionTime:
+```
+
+**Failure handling:**
+- If new task fails to start (bad secret value), ECS circuit breaker stops the rollout
+- Controller sets `status.phase: Failed`, `conditions[].type: SecretInjectionFailed`
+- Old task remains running (ECS deployment circuit breaker preserves last healthy state)
+
+---
+
+## 7. State Store Design (S3-Only)
+
+```
+s3://oab-control-plane/
+ ├── manifests/{namespace}/{name}.yaml ← desired state (oabctl writes)
+ ├── status/{namespace}/{name}.json ← observed state (controller writes)
+ └── artifacts/{namespace}/{name}/{generation}/ ← immutable config per generation
+ └── config.toml
+```
+
+| Concern | Mechanism | Rationale |
+|---------|-----------|-----------|
+| Desired state | `manifests/` prefix | Human-readable, git-syncable, versioned via S3 versioning |
+| Status | `status/` prefix | Controller writes after each reconcile cycle |
+| Config artifacts | `artifacts/` prefix | Controller-rendered config.toml for task startup |
+| Generation tracking | `metadata.generation` in manifest YAML | Explicit counter, not tied to S3 VersionId |
+| Change detection | S3 Event Notifications → EventBridge (Phase 2) | Phase 1 uses polling |
+| Consistency | S3 strong read-after-write | Sufficient for single-controller |
+| Optimistic locking | S3 conditional writes (If-None-Match / ETag) | Prevents concurrent `oabctl apply` conflicts |
+
+### Generation vs S3 VersionId
+
+S3 VersionId is an opaque string — not suitable for comparing "which is newer." Instead:
+- `metadata.generation` is an explicit integer, incremented by `oabctl apply`
+- `status.observedGeneration` records the last generation the controller reconciled
+- Controller skips reconcile if `observedGeneration == generation` (no-op)
+- Stale status writes are detected: if status.observedGeneration < manifest.generation, the status is outdated
+
+### Delete Semantics (Phase 2)
+
+Phase 1: `oabctl delete` removes the manifest from S3; controller detects absence and tears down ECS resources.
+
+Phase 2: Proper deletion with finalizers:
+1. `oabctl delete` sets `metadata.deletionTimestamp` in the manifest (tombstone)
+2. Controller runs finalizers (drain connections, cleanup CloudMap, remove artifacts)
+3. Controller removes manifest and status objects only after all finalizers complete
+
+---
+
+## 8. Controller Upgrade Strategy
+
+The controller runs as a single-replica ECS Service.
+
+### Phase 1 (acceptable brief gap)
+
+```yaml
+# Controller's own ECS Service config
+deploymentConfiguration:
+ minimumHealthyPercent: 0 # allow old to stop before new starts
+ maximumPercent: 100
+```
+
+- ECS rolling update: stop old → start new
+- Brief reconciliation gap (30-60s) during upgrade
+- No in-flight reconcile is lost — next cycle picks up any drift
+- Acceptable for Phase 1 because reconcile is idempotent
+
+### Phase 2 (zero-downtime)
+
+- DynamoDB-based leader election (two controller replicas)
+- Active/standby: standby takes over within seconds if active fails health check
+- Version skew handling: new controller must handle manifests written by old `oabctl` versions (schema backward compatibility)
+
+### Rollback
+
+- Controller image is pinned in its own ECS TaskDefinition
+- Rollback = `aws ecs update-service --task-definition `
+- Controller state is in S3 (stateless process), so rollback is safe
+
+---
+
+## 9. CLI UX (`oabctl`)
+
+### Core Commands
+
+```bash
+oabctl apply -f agent.yaml # declare/update desired state
+oabctl get oabservice # list all services + status
+oabctl get oabservice my-agent # single service detail
+oabctl delete oabservice my-agent # remove (Phase 1: immediate; Phase 2: finalizer)
+oabctl diff -f agent.yaml # show local vs remote diff
+oabctl logs my-agent # shortcut to ECS task logs (CloudWatch)
+oabctl restart my-agent # force new deployment (pick up rotated secrets)
+oabctl snapshot my-agent # capture runtime state → bootstrapFrom archive
+oabctl wait my-agent --for=Available # block until condition met
+```
+
+### `apply` Semantics
+
+```
+$ oabctl apply -f prod/my-agent.yaml
+
+✓ Schema validated (oab.dev/v1 OABService)
+✓ Replicas check passed (replicas=1)
+✓ Uploaded to s3://oab-control-plane/manifests/prod/my-agent.yaml
+✓ Generation: 3 → 4
+⏳ Waiting for reconciliation...
+✓ Service my-agent reconciled (observedGeneration=4, 1/1 tasks running)
+```
+
+### `diff` Granularity
+
+```bash
+oabctl diff -f agent.yaml # spec-only: local YAML vs remote manifest
+oabctl diff -f agent.yaml --rendered # rendered: show generated config.toml diff
+oabctl diff -f agent.yaml --status # include status comparison
+```
+
+### Implementation
+
+`oabctl` talks directly to S3 via AWS SDK. No API server needed. Auth is standard IAM (role, profile, env vars). Config stored in `~/.oabctl/config`:
+
+```toml
+[default]
+region = "us-east-1"
+bucket = "oab-control-plane"
+cluster = "oab-prod"
+```
+
+---
+
+## 10. Phase Scope
+
+### Phase 1 — MVP (target)
+
+| In Scope | Out of Scope |
+|----------|--------------|
+| S3 manifest store (versioning enabled) | EventBridge triggers |
+| Single-instance controller (poll every 30s) | Multi-replica controller / leader election |
+| `oabctl apply` / `oabctl get` | `oabctl delete` with finalizers |
+| Controller renders config.toml → S3 artifact | DynamoDB state store |
+| ECS service create / update | Rollback (`oabctl rollback`) |
+| Startup wrapper downloads config + bootstrap | EFS / shared volumes |
+| `metadata.generation` / `status.observedGeneration` | Multi-region |
+| Per-agent secrets via SSM/SM | Auto-rotation detection |
+| Replicas validation (reject >1) | Auto-scaling policies |
+
+### Phase 2
+
+- `OABFleet` kind + Discord `autoRegister` (batch Bot provisioning)
+- Event-driven triggers (S3 → EventBridge → controller)
+- `oabctl delete` with tombstone + finalizers
+- `oabctl diff`, `oabctl logs`, `oabctl restart`
+- DynamoDB for leader election (active/standby controller)
+- Secret auto-rotation detection + auto-restart
+- Rollback via generation history
+
+### Phase 3
+
+- **K8s Operator** — same `oab.dev/v1` schema consumed as native CRD; `platform.k8s` overlay
+- Multi-region (controller per region, S3 cross-region replication)
+- Dependency graph (service A depends on service B)
+- Auto-scaling policies in manifest spec
+- GitOps integration (GitHub Actions → `oabctl apply` on push)
+- Schema versioning + migration tooling
+
+---
+
+## 11. Alternatives Considered
+
+| Alternative | Why not chosen |
+|-------------|---------------|
+| AWS Proton | Opinionated, limited customization for OAB-specific logic |
+| AWS Copilot | Good for simple apps, no custom reconciliation loop |
+| CDK Pipelines | Deployment tool, not a runtime controller with drift detection |
+| Step Functions orchestrator | Stateless execution model, no continuous reconciliation |
+| Run K8s anyway (EKS) | Valid but adds operational overhead for teams that chose ECS |
+| DynamoDB as primary store | Adds infra; S3 sufficient for single-controller Phase 1 |
+
+---
+
+## 12. Open Questions
+
+1. **Multi-region** — single controller per region, or global controller with regional reconcilers?
+2. **Observability** — CloudWatch metrics from the controller, or push to a shared OAB dashboard?
+3. **Networking isolation** — shared VPC with per-service SG rules, or per-namespace VPC?
+4. **Schema versioning** — how to handle `oab.dev/v2` migration when spec evolves?
+
+---
+
+## 13. Decision
+
+We adopt the CRD + Operator pattern on ECS with an **S3-only state store**, **explicit generation tracking**, and a **`oabctl` CLI** for the operator interface. The controller runs as a single ECS service that reconciles `OABService` manifests against actual ECS state.
+
+Key design choices:
+- **Config delivery**: controller renders `config.toml` to S3 artifact; startup wrapper downloads it
+- **Secrets**: per-agent SSM/Secrets Manager references; never in bootstrap archive
+- **Bootstrap**: mutable runtime state only (memory, knowledge base)
+- **Replicas**: always 1; scale by deploying more agents, not replicating one
+- **Generation**: explicit `metadata.generation` / `status.observedGeneration` (not S3 VersionId)
+- **Phase 1 scope**: narrow (create/update only, poll-based, single controller)
+
+DynamoDB, EventBridge, finalizers, and multi-region are deferred to Phase 2+.
diff --git a/docs/adr/openab-agent.md b/docs/adr/openab-agent.md
new file mode 100644
index 000000000..b1b7bb870
--- /dev/null
+++ b/docs/adr/openab-agent.md
@@ -0,0 +1,462 @@
+# ADR: openab-agent — Native Rust Coding Agent with Built-in ACP
+
+- **Status:** Proposed
+- **Date:** 2026-05-26
+- **Author:** @chaodu-agent
+
+---
+
+## 1. Context & Motivation
+
+Today, every coding agent in OpenAB follows the same pattern:
+
+```
+openab (Rust) ──stdio JSON-RPC──► adapter (Node/Rust) ──spawns──► CLI agent ──HTTP──► LLM API
+```
+
+This introduces 3–4 layers of indirection, each with its own runtime, dependencies, and failure modes. Every agent requires:
+
+- A separate Dockerfile (300–800MB images)
+- A Node.js or Python runtime
+- An ACP adapter wrapper (pi-acp, codex-acp, agy-acp, etc.)
+- npm/pip supply-chain management
+
+Meanwhile, the actual work an agent does is simple:
+
+1. Receive a prompt
+2. Call an LLM API (HTTP POST + SSE)
+3. Execute tool calls (read/write/edit/bash)
+4. Return the result
+
+**Proposal:** Build `openab-agent` — a single Rust binary that is both the ACP server and the coding agent, with no external runtime, no wrapper, and no adapter layer.
+
+---
+
+## 2. Design
+
+### Architecture
+
+```
+┌─ openab-agent (single Rust binary) ──────────────┐
+│ │
+│ ┌─────────────────────────────────────────────┐ │
+│ │ ACP Layer (stdin/stdout JSON-RPC) │ │
+│ │ - session/new, session/prompt, cancel │ │
+│ └──────────────────┬──────────────────────────┘ │
+│ │ │
+│ ┌──────────────────▼──────────────────────────┐ │
+│ │ Agent Core │ │
+│ │ - Prompt assembly (system + user + tools) │ │
+│ │ - Tool dispatch loop │ │
+│ │ - Session tree (branching history) │ │
+│ └──────────────────┬──────────────────────────┘ │
+│ │ │
+│ ┌──────────────────▼──────────────────────────┐ │
+│ │ LLM Client (reqwest + SSE) │ │
+│ │ - OpenAI-compatible (GPT, Codex, DeepSeek) │ │
+│ │ - Anthropic (Claude) │ │
+│ │ - Google (Gemini) │ │
+│ └─────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────┐ │
+│ │ Tools (4 only) │ │
+│ │ - read: file/directory reading │ │
+│ │ - write: file creation │ │
+│ │ - edit: string replacement in files │ │
+│ │ - bash: command execution (sandboxed) │ │
+│ └─────────────────────────────────────────────┘ │
+│ │
+└───────────────────────────────────────────────────┘
+```
+
+### Comparison with Existing Agents
+
+| Aspect | Existing (e.g. Pi, Codex) | openab-agent |
+|--------|---------------------------|--------------|
+| Layers | openab → adapter → CLI → LLM | openab → **agent** → LLM |
+| Runtime | Node.js / Python | None (static binary) |
+| Image size | 300–800MB | ~20MB (distroless) |
+| Cold start | 1–3s | <50ms |
+| ACP support | Requires wrapper | Native, zero overhead |
+| Dependencies | npm ecosystem | Minimal crates |
+| Supply-chain risk | High (node_modules) | Low (cargo audit) |
+
+### Required Crates
+
+Only four crates are needed beyond what openab core already uses:
+
+- `reqwest` — HTTP client (LLM API calls)
+- `serde` / `serde_json` — JSON serialization
+- `tokio` — async runtime + process management (`tokio::process`) (already used in openab)
+- `futures` — `Stream` trait and `BoxStream` for async streaming
+
+> **Note:** `tokio-process` was merged into `tokio::process` in tokio 0.2 and the standalone crate is deprecated. All process spawning uses `tokio::process::Command` directly.
+
+Nothing else. Can share code with openab core (ACP types, session pool logic).
+
+### Key Advantage: Deep Integration
+
+Because we own the agent and it shares the same language as openab core, deep integration is possible — a future library mode can bypass stdio entirely, using in-process function calls to eliminate all IPC overhead.
+
+### Design Principles (inspired by Pi)
+
+1. **Minimal tool surface** — 4 tools only (read, write, edit, bash). Maximizes context window for actual code.
+2. **Tiny system prompt** — Agent instructions fit in ~500 tokens. No bloated tool descriptions.
+3. **Multi-model** — Provider-agnostic. Switch models via config or mid-session command.
+4. **Session trees** — Branching conversation history. Explore multiple approaches without losing context.
+5. **No SDK dependency** — LLM APIs are just HTTP. A thin `reqwest` client (~300 lines) covers all providers.
+
+---
+
+## 3. LLM Client Design
+
+The LLM client is intentionally thin — no SDK, just HTTP:
+
+```rust
+use futures::stream::BoxStream;
+
+// Unified trait for all providers
+trait LlmProvider: Send + Sync {
+ fn chat<'a>(
+ &'a self,
+ messages: &'a [Message],
+ tools: &'a [Tool],
+ ) -> Pin>> + Send + 'a>>;
+}
+
+// Implementations are ~100 lines each
+struct OpenAiProvider { base_url: String, api_key: String, model: String }
+struct AnthropicProvider { api_key: String, model: String }
+struct GoogleProvider { api_key: String, model: String }
+```
+
+> **Note:** `Stream` is a trait (from `futures`), not a concrete type. Returning it directly from a trait method would not compile. We use `BoxStream<'a, Event>` (i.e., `Pin + Send + 'a>>`) to provide a type-erased, object-safe return type. The `async fn` in traits is similarly desugared to a boxed future for object safety.
+
+All three major APIs follow the same pattern:
+- POST JSON body with messages + tool definitions
+- Stream SSE events back
+- Parse tool_use / function_call blocks
+- Loop until stop
+
+Provider differences are minor (header format, JSON schema for tools, SSE event names) and well-contained in ~300 lines per provider.
+
+### API Change Tracking & Version Pinning
+
+Without an official SDK, API changes must be tracked deliberately. Strategy:
+
+- **Pin API versions in headers**: `anthropic-version: 2023-06-01`, `x-api-version` for OpenAI (when available)
+- **Model version pinning**: use dated model snapshots (e.g., `claude-sonnet-4-20250514`) not aliases (`claude-sonnet-4`) in default config
+- **CI canary job**: weekly integration test against each provider's API with a minimal prompt. Failures trigger alerts, not breakage.
+- **Provider feature flags**: new API features (extended thinking, computer use, etc.) are gated behind feature flags, not auto-enabled
+- **Changelog tracking**: maintain `PROVIDERS.md` documenting supported API versions, known breaking changes, and migration notes
+- **OpenAI-compatible fallback**: providers implementing the OpenAI chat completions spec (DeepSeek, Groq, Together, Ollama, etc.) require zero additional code — only `base_url` changes
+
+---
+
+## 4. Tool Implementation
+
+| Tool | Input | Behavior |
+|------|-------|----------|
+| `read` | path, optional line range | Read file contents or list directory |
+| `write` | path, content | Create or overwrite file |
+| `edit` | path, old_str, new_str | Replace exact string in file |
+| `bash` | command, optional working_dir | Execute shell command, return stdout/stderr |
+
+### Path Security (read/write/edit tools)
+
+All file tools enforce **path confinement** to prevent path traversal attacks:
+
+- All paths are canonicalized (`std::fs::canonicalize`) before access
+- Resolved path must be within `working_dir` or explicitly allowed directories
+- Symlinks are resolved and checked against the boundary
+- Attempts to escape (e.g., `../../etc/passwd`) return an error, not file contents
+
+```rust
+fn validate_path(path: &Path, working_dir: &Path) -> Result {
+ let canonical = path.canonicalize()?;
+ if !canonical.starts_with(working_dir) {
+ return Err(Error::PathTraversal(path.display().to_string()));
+ }
+ Ok(canonical)
+}
+```
+
+### Sandboxing (bash tool)
+
+The `bash` tool runs commands via `tokio::process::Command` with:
+
+- Configurable timeout (default: 120s)
+- **Process group kill on timeout** — uses `setsid` + `kill(-pgid)` to ensure all child/grandchild processes are terminated, preventing orphan process leaks
+- Optional `bubblewrap` (bwrap) sandboxing on Linux; falls back to basic process isolation on macOS (see Cross-Platform Sandboxing below)
+- Working directory scoped to agent's `working_dir`
+- No network access restriction by default (agent needs to call APIs, git, etc.)
+
+### Environment Variable Filtering
+
+The `bash` tool does **NOT** inherit the agent's full environment. Instead:
+
+- **Deny-list by default**: sensitive variables are stripped before spawning child processes:
+ - `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `OPENAB_AGENT_*` (all provider keys)
+ - Any variable matching `*_SECRET`, `*_TOKEN`, `*_KEY` patterns (configurable)
+- **Allow-list passthrough**: only explicitly declared safe variables are inherited:
+ - `PATH`, `HOME`, `USER`, `LANG`, `TERM`, `SHELL`
+ - Variables listed in `OPENAB_AGENT_BASH_ENV_ALLOW` (comma-separated)
+- This prevents prompt injection attacks from exfiltrating API keys via `curl`/`wget`
+
+```rust
+fn build_env(config: &AgentConfig) -> HashMap {
+ let mut env = HashMap::new();
+ // Only pass safe defaults
+ for key in &["PATH", "HOME", "USER", "LANG", "TERM", "SHELL"] {
+ if let Ok(val) = std::env::var(key) {
+ env.insert(key.to_string(), val);
+ }
+ }
+ // Add user-configured allow-list
+ for key in &config.bash_env_allow {
+ if let Ok(val) = std::env::var(key) {
+ env.insert(key.to_string(), val);
+ }
+ }
+ env
+}
+```
+
+### Cross-Platform Sandboxing
+
+| Platform | Sandboxing | Notes |
+|----------|-----------|-------|
+| Linux | `bubblewrap` (bwrap) | Full filesystem/network namespace isolation |
+| macOS | Process group isolation + env filtering | Primary mechanism for local dev. `sandbox-exec` is deprecated by Apple and may be removed in future macOS versions — not relied upon. |
+| Fallback | Process group isolation + env filtering | Minimum viable security without platform-specific tools |
+
+For production (Linux containers), `bubblewrap` is the primary mechanism. For local macOS development, the env filtering + path confinement provides baseline security without requiring bwrap.
+
+---
+
+## 5. Session Tree
+
+Sessions are stored as a tree structure, not a flat list:
+
+```
+root
+├── turn-1 (user: "explain this code")
+│ └── turn-2 (assistant: "This code does...")
+│ ├── turn-3a (user: "refactor it") ← branch A
+│ │ └── turn-4a (assistant: "Here's the refactored...")
+│ └── turn-3b (user: "add tests instead") ← branch B
+│ └── turn-4b (assistant: "Here are the tests...")
+```
+
+Benefits:
+- Explore multiple approaches from a decision point
+- Rollback without losing the exploration
+- Persisted to disk as JSON for session resume
+
+### Garbage Collection / Pruning
+
+To prevent unbounded growth in long-running deployments:
+
+- **Max tree depth**: configurable (default: 200 turns per branch). Oldest turns are summarized when exceeded.
+- **Max branches**: configurable (default: 20 per session). Least-recently-used branches are pruned on overflow.
+- **Inactive branch TTL**: branches not accessed for N hours (default: 24h) are eligible for pruning.
+- **Disk persistence cap**: per-session JSON file capped at 10MB. Exceeding triggers forced summarization of oldest branches.
+- **GC trigger**: runs on every Nth turn (default: 10) or when memory pressure is detected.
+
+---
+
+## 5a. Context Window Management
+
+The agent must operate within LLM context limits (typically 128K–200K tokens). Strategy:
+
+### Token Counting
+
+- Use `tiktoken-rs` for OpenAI models, character-based estimation (×0.3) for others
+- Track cumulative token usage per session branch
+
+### Window Overflow Strategy (ordered by priority)
+
+1. **Tool output truncation** — large `bash` stdout or `read` results are truncated to configurable max (default: 30K tokens) with a "truncated, showing first/last N lines" indicator
+2. **Oldest turn summarization** — when context exceeds 80% of model limit, oldest turns (excluding system prompt and last 4 turns) are replaced with a one-paragraph summary generated by the same LLM
+3. **Branch instead of truncate** — if the user explicitly branches, the new branch starts with a compact summary of the parent path, preserving full context in the original branch
+4. **Hard cap rejection** — if a single tool output exceeds 50% of context window, reject and ask the user to narrow the request
+
+### Configuration
+
+```toml
+[agent.context]
+max_context_percent = 80 # trigger summarization at 80% of model limit
+max_tool_output_tokens = 30000 # truncate individual tool outputs
+summarize_after_turns = 20 # summarize turns older than the last 20
+```
+
+---
+
+## 6. Configuration
+
+```toml
+[agent]
+command = "openab-agent"
+working_dir = "/home/agent"
+
+[agent.env]
+# Provider selection (one of):
+OPENAB_AGENT_PROVIDER = "anthropic" # or "openai", "google", "openai-compatible"
+OPENAB_AGENT_MODEL = "claude-sonnet-4-20250514"
+
+# Auth (provider-specific):
+ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}"
+# or: OPENAI_API_KEY, GOOGLE_API_KEY, etc.
+
+# Optional:
+OPENAB_AGENT_MAX_TOKENS = "8192"
+OPENAB_AGENT_TIMEOUT_SECS = "120"
+```
+
+### Steering Files
+
+openab-agent reads steering files in the same pattern as other agents:
+
+- `AGENTS.md` in working directory (hot memory, always loaded)
+- `.openab-agent/system.md` — custom system prompt override
+- `.openab-agent/append.md` — append to default system prompt
+
+---
+
+## 7. Future: Library Mode (Deferred — Not in v1 Scope)
+
+> **Note:** This section documents a potential future optimization. It is explicitly **out of scope** for v0.1–v0.3 and will require its own ADR if pursued.
+
+Because openab-agent is Rust (same as openab core), a future optimization is **in-process mode** — no stdio, no JSON-RPC serialization:
+
+```rust
+// Current: IPC over stdio (v0.1–v0.3)
+openab::spawn_process("openab-agent", &["--acp"]) // stdin/stdout JSON-RPC
+
+// Future: direct function call (requires separate ADR)
+let agent = openab_agent::Agent::new(config);
+let response = agent.prompt(session_id, messages).await; // zero-copy
+```
+
+### Known Risks (to be addressed in future ADR)
+
+- **Panic propagation**: an agent panic (e.g., malformed SSE parse) would crash the entire openab process. Mitigation: `catch_unwind` boundaries or `tokio::task::spawn` with panic hooks.
+- **Resource isolation**: in-process mode shares memory/threads with openab core. A runaway agent could starve the session pool.
+- **Blast radius**: process isolation (current design) provides natural fault containment. Library mode trades this for performance.
+
+**Decision**: v1 ships as a standalone binary with stdio ACP. Library mode is a v2+ exploration only if IPC overhead proves to be a measurable bottleneck in production.
+
+---
+
+## 8. Rollout Plan
+
+| Phase | Scope | Deliverable |
+|-------|-------|-------------|
+| **v0.1** | Scaffold + ACP layer + single provider (Anthropic) | Working agent, 4 tools, flat session |
+| **v0.2** | Multi-provider + session tree + steering files | Feature parity with Pi's core |
+| **v0.3** | Dockerfile + Helm chart + CI | Production-ready deployment |
+| **v0.4** | Library mode exploration | In-process integration with openab core |
+
+---
+
+## 9. Testing Strategy
+
+### Unit Test Boundaries
+
+Following the project's unit test ADR, operations involving network, filesystem, or subprocess are **integration tests only**. Unit tests cover pure logic:
+
+| Layer | Unit-Testable | How |
+|-------|--------------|-----|
+| Prompt assembly | ✅ | Hand-written mock `LlmProvider` returning canned `BoxStream` |
+| Tool dispatch routing | ✅ | Mock tool implementations (no real FS/process) |
+| Session tree operations | ✅ | Pure data structure manipulation |
+| Token counting / context management | ✅ | Pure computation |
+| SSE event parsing | ✅ | Feed raw bytes, assert parsed `Event` structs |
+| LLM HTTP calls | ❌ (integration) | Real HTTP against provider or local mock server |
+| File tools (read/write/edit) | ❌ (integration) | Real filesystem in temp dirs |
+| Bash tool | ❌ (integration) | Real subprocess execution |
+
+### Hand-Written Mocks (no `mockall`)
+
+Per team convention, all mocks are hand-written:
+
+```rust
+struct MockLlmProvider {
+ responses: Vec>,
+ call_count: AtomicUsize,
+}
+
+impl LlmProvider for MockLlmProvider {
+ fn chat<'a>(
+ &'a self,
+ _messages: &'a [Message],
+ _tools: &'a [Tool],
+ ) -> Pin>> + Send + 'a>> {
+ let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
+ let events = self.responses[idx].clone();
+ Box::pin(async move {
+ Ok(Box::pin(futures::stream::iter(events.into_iter())) as BoxStream<'a, Event>)
+ })
+ }
+}
+```
+
+### Integration Tests
+
+- Tagged with `#[cfg(test)]` + `#[ignore]` for CI gating
+- LLM integration tests require `OPENAB_TEST_PROVIDER` env var
+- File/bash tool tests use `tempdir` for isolation
+- CI runs integration tests in a separate job with real credentials (not on every PR)
+
+### CI Pipeline
+
+```
+PR push → cargo fmt --check → cargo clippy → cargo test (unit only)
+ ↓
+merge to main → cargo test (unit + integration) → canary deploy
+```
+
+---
+
+## 10. Open Questions
+
+| Question | Options | Notes |
+|----------|---------|-------|
+| **Crate name** | `openab-agent` as a workspace member vs separate repo | Workspace member keeps it close to openab core |
+| **Subscription auth** | Support OAuth flows (Claude Pro, ChatGPT Plus) or API-key only for v1? | API-key only for v1; subscription auth adds complexity |
+| **Permission model** | Auto-approve all tool calls vs interactive approval? | Auto-approve for v1 (matches OpenAB's `--trust-all-tools` pattern) |
+| **Context window management** | Truncate old turns vs summarize vs session tree branching? | Session tree branching for v1; summarization for v2 |
+
+---
+
+## Consequences
+
+### Positive
+
+- **Zero external runtime** — no Node.js, Python, or npm. Single static binary.
+- **Minimal attack surface** — no node_modules supply chain, no adapter layer vulnerabilities.
+- **Fastest cold start** — <50ms vs 1–3s for Node-based agents.
+- **Smallest image** — ~20MB distroless vs 300–800MB for existing agents.
+- **Native ACP** — no wrapper overhead, no adapter bugs, no version mismatches.
+- **Same language as openab** — shared types, potential library mode, unified toolchain.
+- **Full control** — no upstream CLI breaking changes; we own the entire stack.
+
+### Negative
+
+- **LLM API maintenance** — must track API changes manually without official SDKs.
+- **No subscription auth (v1)** — API key only initially; users with Claude Pro/ChatGPT Plus subscriptions still need Pi or Codex.
+- **Feature gap** — v1 will lack features mature agents have (image support, MCP, web search tools).
+- **Development effort** — building from scratch vs leveraging existing open-source agents.
+
+### Risks
+
+- **API instability** — if providers make breaking changes frequently, maintenance burden grows. Mitigated by pinning API versions and weekly CI canary.
+- **Scope creep** — temptation to add more tools/features. Mitigated by the "4 tools only" design principle as a hard constraint for v1.
+
+---
+
+## References
+
+- [Pi coding agent](https://github.com/earendil-works/pi) — design inspiration (minimal tools, session trees, multi-model)
+- [Agent Client Protocol](https://github.com/anthropics/agent-protocol) — ACP spec
+- [OpenAB](https://github.com/openabdev/openab) — host runtime
diff --git a/docs/adr/turn-boundary-batching.md b/docs/adr/turn-boundary-batching.md
index 6d9cc909b..de147e59a 100644
--- a/docs/adr/turn-boundary-batching.md
+++ b/docs/adr/turn-boundary-batching.md
@@ -4,7 +4,7 @@
- **Date:** 2026-04-29
- **Author:** @brettchien
- **Tracking issue:** [#580](https://github.com/openabdev/openab/issues/580) — kept as historical discussion record
-- **Implementation PR:** [#686](https://github.com/openabdev/openab/pull/686) (Phase 1 wiring; this ADR documents the design it lands)
+- **Implementation PR:** [#686](https://github.com/openabdev/openab/pull/686) (Phase 1 wiring; the ADR documents the design that PR implements)
- **Related:** [#78](https://github.com/openabdev/openab/issues/78) (Session Management — precondition), [#58](https://github.com/openabdev/openab/issues/58) (per-connection locking — precondition), [#307](https://github.com/openabdev/openab/issues/307) (cross-session blocking — adjacent symptom of §2.7)
- **Anchor pinning:**
- **Released-code anchors (file:line) — pinned to v0.8.2-beta.1** ([`52052b8`](https://github.com/openabdev/openab/commit/52052b8b104a85a7073dd6ae99eeb9f2fd331abe)). All `acp/connection.rs:NNN`, `acp/pool.rs:NNN`, `adapter.rs:NNN`, `discord.rs:NNN`, `slack.rs:NNN` references resolve at this SHA. They will drift against later commits — that's expected; the ADR documents the *design* relative to a stable base, not a moving target.
@@ -360,7 +360,7 @@ For a single-message dispatch (`batch.len() == 1`) the minimum is two blocks: de
| Source | Value |
|---|---|
| Discord adapter | `msg.timestamp` (serenity 0.12 `Timestamp`, RFC 3339 by default) |
-| Slack adapter | `slack_ts_to_iso8601(event.ts)` — converts epoch-seconds-with-fractional to ISO 8601 with millisecond precision |
+| Slack adapter | `slack_ts_to_iso8601(event.ts)` (proposed helper) — converts epoch-seconds-with-fractional to ISO 8601 with millisecond precision |
| Gateway adapter | `chrono::Utc::now().to_rfc3339()` at receive time — best-effort for non-Discord/Slack channels; documented as approximate |
`schema` stays `openab.sender.v1` — the field is additive and existing parsers keep working. Two purposes:
@@ -788,6 +788,8 @@ The rules below operationalize I3 (broker structural fidelity). Together they fo
1. **Broker forwards `{prompt}` verbatim.** Broker must not parse, classify, transform, summarize, or annotate the user-supplied text content within `{prompt}`. Any future feature that needs to inspect `{prompt}` content must do so without mutating what the agent receives.
+ *Note: Adapter-level preprocessing that runs before `{prompt}` is constructed (e.g. `resolve_mentions()` in `discord.rs`) is not subject to this rule. This rule applies to the broker/dispatcher layer — i.e. from `Dispatcher::submit` onward.*
+
**Counter-examples (prohibited):** broker stripping markdown formatting before dispatch; broker expanding Discord `<@123>` mentions to `@username` strings; broker appending an `[image attached]` string when an image accompanies the prompt; broker collapsing repeated whitespace; broker normalizing Unicode forms.
2. **No banners or framing strings.** Broker must not inject any leading or trailing instruction text into the dispatched batch (e.g. no `[Batched: N messages…]`, no `[End of batch]`). All metadata lives in `` JSON.
@@ -804,7 +806,7 @@ The rules below operationalize I3 (broker structural fidelity). Together they fo
7. **Splitting only at message boundaries.** When the token-budget cap (`max_batch_tokens`) forces a batch to split across multiple ACP turns, the split must occur between two arrival events — never inside a single arrival event. A single oversized message dispatches alone; the broker does not truncate or summarize it.
-8. **No silent failure on consumer death.** When `submit` observes `SendError` (consumer task death), the failure must surface as ❌ on `msg.trigger_msg` **and** `⚠️ {format_user_error}` text in the channel **and** `Err` propagated to the caller. Already-enqueued messages whose `submit` already returned `Ok` are residual loss equivalent to a pod restart mid-turn (documented; out of Phase 1 scope to recover). Messages in the consumer's in-flight batch at the time of the panic are also residual loss — their `submit` already returned `Ok` before the consumer died, so they cannot be reacted from the `SendError` path.
+8. **No silent failure on consumer death (retry-failed case).** When `submit` observes `SendError` (consumer task death), it first attempts a transparent retry — evict the dead consumer, spawn a fresh one, and re-send (§2.5). The first `SendError` is absorbed silently because the dominant cause is the benign first-message-after-idle race. Only when the **retry also fails** must the failure surface as ❌ on `msg.trigger_msg` **and** `⚠️ {format_user_error}` text in the channel **and** `Err` propagated to the caller. Already-enqueued messages whose `submit` already returned `Ok` are residual loss equivalent to a pod restart mid-turn (documented; out of Phase 1 scope to recover). Messages in the consumer's in-flight batch at the time of the panic are also residual loss — their `submit` already returned `Ok` before the consumer died, so they cannot be reacted from the `SendError` path.
9. **`bot_turns` runs at ingest, not at dispatch.** Multi-bot loop guards (`slack.rs:672-696`) execute before `submit`; batching is downstream and cannot bypass them. Bot-turn-limit counts batches as turns (one ACP invocation = one logical turn); the per-message ingest counter is unchanged.
@@ -848,7 +850,7 @@ info_span!("dispatch", channel = %channel_id, adapter = "discord")
Per-event metrics fold into the per-dispatch line as array fields → log line count = dispatch count, independent of batch size.
-**Threshold for dedup re-evaluation:** when `p95_batch_size × avg_tokens_per_event > 500 tokens` (used as a rough proxy for per-dispatch `` envelope overhead) on any production channel for a sustained 24h window, the broker team must re-open the dedup question (e.g. emit `` only when sender or timestamp delta changes). Below that threshold the envelope cost is below noise and the readability win of always-explicit headers wins.
+**Threshold for dedup re-evaluation:** when `p95_batch_size (count) × avg_tokens_per_event (tokens) > 500 tokens` of per-dispatch `` envelope overhead on any production channel for a sustained 24h window, the broker team must re-open the dedup question (e.g. emit `` only when sender or timestamp delta changes). Below that threshold the envelope cost is below noise and the readability win of always-explicit headers wins.
**Phase 1 acceptance test (masami #1):** after Phase 1 lands and is deployed to a test channel, send a 3-message batch and verify the single `info!` line carries `events_per_dispatch = 3`, `packed_block_count = N`, `agent_dispatch_ms = N`, `tokens_per_event = [t1, t2, t3]`, `wait_ms = [w1, w2, w3]`. If any field is missing or events are split across multiple log lines, Phase 1 does not merge.
diff --git a/docs/antigravity.md b/docs/antigravity.md
new file mode 100644
index 000000000..6022edebf
--- /dev/null
+++ b/docs/antigravity.md
@@ -0,0 +1,79 @@
+# Google Antigravity CLI (agy)
+
+OpenAB supports [Google Antigravity CLI](https://antigravity.google/) via the `agy-acp` adapter — a thin Rust binary that translates ACP JSON-RPC into `agy -p` invocations.
+
+## How It Works
+
+```
+openab ──ACP JSON-RPC──► agy-acp ──spawns──► agy --add-dir /home/agent -p "prompt"
+ agy --add-dir /home/agent --conversation -p "follow-up"
+```
+
+- First prompt in a session: `agy -p "text"`, then discovers the conversation ID
+- Subsequent prompts: `agy --conversation -p "text"` (resumes specific conversation)
+- Only the **delta** (new response) is sent back — previous turns are not repeated
+- Full `` metadata is passed through to agy
+
+## Configuration
+
+```toml
+[agent]
+command = "agy-acp"
+args = []
+working_dir = "/home/agent"
+```
+
+### Environment Variables
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `AGY_WORKING_DIR` | Working directory for agy invocations | `/tmp` |
+| `AGY_EXTRA_ARGS` | Extra arguments prepended to every `agy` invocation (optional) | (none) |
+
+## Steering Files
+
+agy reads `AGENTS.md` and `GEMINI.md` when it considers a directory a workspace:
+
+1. `AGENTS.md` and `GEMINI.md` are loaded first and injected into the system prompt
+2. agy does not disclose how it determines HOME as a workspace, but `--add-dir` explicitly adds a directory
+3. agy-acp **automatically** passes `--add-dir ` on every invocation — no configuration needed
+
+Place your steering instructions in `/home/agent/AGENTS.md` or `/home/agent/GEMINI.md` — they will be read on every prompt as long as `working_dir` points to that directory.
+
+## Docker
+
+```bash
+docker build -f Dockerfile.antigravity -t openab-antigravity .
+```
+
+## Authentication
+
+Antigravity CLI uses Google Sign-In (OAuth). Authenticate inside the container:
+
+```bash
+kubectl exec -it deployment/openab-antigravity -- /lib64/ld-linux-x86-64.so.2 /usr/local/bin/agy auth
+```
+
+Complete the device flow in your browser. Auth tokens persist in the PVC at `~/.gemini/`.
+
+## Helm
+
+```yaml
+agents:
+ antigravity:
+ discord:
+ botToken: "${DISCORD_BOT_TOKEN}"
+ allowedChannels: ["123456789"]
+ agent:
+ command: "agy-acp"
+ args: []
+ workingDir: "/home/agent"
+ image:
+ repository: ghcr.io/openabdev/openab-antigravity
+ tag: "latest"
+```
+
+## Limitations
+
+- **No streaming**: `agy -p` returns the full response at once; the adapter sends it as a single `agent_message_chunk` notification.
+- **Cancel is a no-op**: `agy -p` runs to completion; `session/cancel` acknowledges but cannot interrupt.
diff --git a/docs/codex.md b/docs/codex.md
index 50d1b6934..ca6e23eec 100644
--- a/docs/codex.md
+++ b/docs/codex.md
@@ -250,6 +250,46 @@ codex exec --dangerously-bypass-approvals-and-sandbox ...
Do not use this flag on an untrusted host.
+### `bubblewrap is unavailable: no system bwrap was found on PATH`
+
+Codex's Linux sandbox modes (read-only / workspace-write) rely on `bwrap`
+(bubblewrap) to create an inner sandbox. If the runtime image does not include
+bubblewrap, even basic commands like `pwd` or `ls` will fail before execution
+with this error.
+
+This commonly happens in OpenAB deployments where Codex already runs inside an
+isolated container or VM — the outer runtime provides the desired isolation, so
+the inner sandbox is redundant.
+
+**Solution — Disable Codex's inner sandbox** (recommended when the outer OpenAB
+runtime already provides isolation):
+
+```toml
+# /home/node/.codex/config.toml
+[sandbox]
+sandbox_mode = "danger-full-access"
+approval_policy = "on-request"
+```
+
+Or launch with:
+
+```bash
+codex --sandbox danger-full-access
+```
+
+Or via Helm:
+
+```bash
+helm install openab openab/openab \
+ --set-json 'agents.codex.extraConfig={"sandbox":{"sandbox_mode":"danger-full-access","approval_policy":"on-request"}}'
+```
+
+> **Important:** `danger-full-access` disables only Codex's *inner* sandbox. It
+> does **not** remove the outer OpenAB container/VM isolation. The agent remains
+> confined by the runtime's own security boundary. Ensure the outer runtime is a
+> non-privileged container (no `--privileged` flag or excessive capabilities) for
+> this security model to hold.
+
### Imagegen appears to hang
Check whether an image was generated even if the CLI has not returned yet:
diff --git a/docs/config-reference.md b/docs/config-reference.md
index 622dd7d3a..e93e48314 100644
--- a/docs/config-reference.md
+++ b/docs/config-reference.md
@@ -41,6 +41,9 @@ Discord adapter. Requires a Discord bot token.
| `allow_user_messages` | string | `"involved"` | `"involved"` — reply in threads bot has participated in without @mention; channel messages require @mention; DMs always process. `"mentions"` — always require @mention. `"multibot-mentions"` — like `"involved"`, but require @mention once another bot has posted in the thread. |
| `allow_dm` | bool | `false` | `true` = respond to Discord DMs; `false` = ignore DMs. `allowed_users` still applies in DMs. Each DM user consumes one session slot. |
| `max_bot_turns` | u32 | `100` | Max consecutive bot turns per thread before throttling (soft limit). Human message resets the counter. A compiled-in hard cap of 1000 consecutive bot messages is always enforced. |
+| `message_processing_mode` | string | `"per-message"` | Message dispatch mode: `"per-message"` (each message = own turn), `"per-thread"` (all messages in thread share one buffer), or `"per-lane"` (each sender gets own buffer). See [Message Dispatch Modes](message-dispatch-modes.md). |
+| `max_buffered_messages` | u32 | `10` | Per-thread/lane mpsc channel capacity. Only applies to `per-thread` / `per-lane` modes. |
+| `max_batch_tokens` | u32 | `24000` | Soft token cap per ACP turn. Only applies to `per-thread` / `per-lane` modes. |
---
@@ -57,9 +60,12 @@ Slack adapter using Socket Mode. Requires both a Bot User OAuth Token and an App
| `allow_all_users` | bool \| omit | auto-detect | Same behavior as Discord. |
| `allowed_users` | string[] | `[]` | Slack user IDs (e.g. `U0123456789`). |
| `allow_bot_messages` | string | `"off"` | Same as Discord. |
-| `trusted_bot_ids` | string[] | `[]` | Slack Bot User IDs (`U...`). Find via: click bot profile → Copy member ID. |
+| `trusted_bot_ids` | string[] | `[]` | Slack Bot User IDs (`U...`) or Bot IDs (`B...`). `U...` matching resolves event Bot IDs via Slack `bots.info`, so the bot token needs `users:read`. |
| `allow_user_messages` | string | `"involved"` | Same as Discord. |
| `max_bot_turns` | u32 | `100` | Same as Discord. |
+| `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). |
+| `max_buffered_messages` | u32 | `10` | Same as Discord. |
+| `max_batch_tokens` | u32 | `24000` | Same as Discord. |
---
@@ -77,6 +83,9 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl
| `allowed_channels` | string[] | `[]` | Chat/group IDs to allow. Only checked when `allow_all_channels` resolves to false. |
| `allow_all_users` | bool \| omit | auto-detect | `true` = any user; `false` = only `allowed_users`. Omitted = inferred from list. |
| `allowed_users` | string[] | `[]` | User IDs to allow. Only checked when `allow_all_users` resolves to false. |
+| `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). |
+| `max_buffered_messages` | u32 | `10` | Same as Discord. |
+| `max_batch_tokens` | u32 | `24000` | Same as Discord. |
---
@@ -86,10 +95,10 @@ The AI agent subprocess that OpenAB spawns to handle messages via ACP.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
-| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude`, `codex`, `gemini`, `copilot`, `opencode`, `cursor-agent`). |
+| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude-agent-acp`, `codex`, `gemini`, `copilot`, `opencode`, `pi-acp`, `cursor-agent`). |
| `args` | string[] | `[]` | CLI arguments passed to the agent. |
| `working_dir` | string | `"/tmp"` | Working directory for the agent process. |
-| `env` | map | `{}` | Extra environment variables (e.g. `{ ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" }`). |
+| `env` | map | `{}` | Extra environment variables (e.g. `{ OPENAI_API_KEY = "${OPENAI_API_KEY}" }`). |
| `inherit_env` | string[] | `[]` | Env var names to inherit from the OAB process (e.g. vars injected via K8s `envFrom`). Keys in `env` take precedence. |
> **Default inherited vars:** After `env_clear()`, the agent always receives `HOME`, `PATH`, and `USER` (on Windows: `USERPROFILE`, `USERNAME`, `PATH`, `SystemRoot`, `SystemDrive`). Use `inherit_env` to pass additional vars beyond this baseline.
@@ -105,10 +114,11 @@ working_dir = "/home/agent"
# Claude Code
[agent]
-command = "claude"
-args = ["--acp"]
+command = "claude-agent-acp"
+args = []
working_dir = "/home/node"
-env = { ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" }
+# Auth: kubectl exec -it deploy/openab-claude -- claude auth login
+# Credentials persist in HOME PVC across restarts. See docs/claude-code.md.
# Codex
[agent]
@@ -136,11 +146,21 @@ command = "opencode"
args = ["acp"]
working_dir = "/home/node"
+# Pi Agent
+[agent]
+command = "pi-acp"
+working_dir = "/home/node"
+
# Cursor Agent
[agent]
command = "cursor-agent"
args = ["acp", "--model", "auto", "--workspace", "/home/agent"]
working_dir = "/home/agent"
+
+# Hermes Agent
+[agent]
+command = "hermes-acp"
+working_dir = "/home/agent"
```
---
@@ -204,6 +224,7 @@ Speech-to-text transcription for voice messages. Uses an OpenAI-compatible `/aud
| `api_key` | string | `""` | API key for the STT service. When empty and `base_url` contains `groq.com`, the `GROQ_API_KEY` environment variable is used automatically. For local servers, use `api_key = "not-needed"`. |
| `model` | string | `"whisper-large-v3-turbo"` | Model name to use for transcription. |
| `base_url` | string | `"https://api.groq.com/openai/v1"` | Base URL of the STT API. Any OpenAI-compatible `/audio/transcriptions` endpoint works. |
+| `echo_transcript` | bool | `false` | When set to `true` and STT runs, post a `> 🎤 ` message to the thread before the agent reply so users can verify what was heard. Failures show `(transcription failed)` and add a ⚠️ reaction to the original message. |
---
@@ -256,6 +277,33 @@ timezone = "UTC"
The external `cronjob.toml` uses `[[jobs]]` (same fields). See [Usercron docs](cronjob.md#usercron--hot-reload-with-cronjobtoml) for details.
+### Usercron-only `[[jobs]]` fields
+
+These fields are valid only in the external usercron file, for example `$HOME/.openab/cronjob.toml`. They are rejected in baseline `[[cron.jobs]]` because OpenAB only writes state back to the user-managed cron file.
+
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| `id` | string | *required with `disable_on_success`* | Stable job ID used when the scheduler writes `enabled = false` or `thread_id` back to `cronjob.toml`. |
+| `disable_on_success` | string | — | Command to run before sending the scheduled prompt. |
+| `disable_on_success_match` | string | *required with `disable_on_success`* | Marker that must appear in stdout or stderr, in addition to exit code `0`, before the job is considered complete. |
+| `disable_on_success_timeout_secs` | integer | `60` | Timeout for the completion check command. |
+| `disable_on_success_working_dir` | string | — | Working directory for the completion check command. |
+
+Example:
+
+```toml
+[[jobs]]
+id = "fix-unit-tests"
+enabled = true
+schedule = "*/10 * * * *"
+channel = "123456789"
+message = "Unit tests are still failing. Continue fixing them."
+disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS"
+disable_on_success_match = "OPENAB_GOAL_SUCCESS"
+disable_on_success_timeout_secs = 120
+disable_on_success_working_dir = "/workspace/my-project"
+```
+
**Cron expression format:**
```
@@ -308,6 +356,9 @@ Key mapping (`values.yaml` → `config.toml`):
| `agents..discord.allowBotMessages` | `[discord] allow_bot_messages` |
| `agents..discord.trustedBotIds` | `[discord] trusted_bot_ids` |
| `agents..discord.allowUserMessages` | `[discord] allow_user_messages` |
+| `agents..discord.messageProcessingMode` | `[discord] message_processing_mode` |
+| `agents..discord.maxBufferedMessages` | `[discord] max_buffered_messages` |
+| `agents..discord.maxBatchTokens` | `[discord] max_batch_tokens` |
| `agents..slack.*` | `[slack] *` (same pattern) |
| `agents..pool.maxSessions` | `[pool] max_sessions` |
| `agents..pool.sessionTtlHours` | `[pool] session_ttl_hours` |
diff --git a/docs/copilot.md b/docs/copilot.md
index 0e73e58e1..6ad44316e 100644
--- a/docs/copilot.md
+++ b/docs/copilot.md
@@ -140,13 +140,20 @@ helm install openab-copilot openab/openab \
## Model Selection
-Copilot CLI defaults to Claude Sonnet 4.6. Other available models include:
+The default model is defined in `~/.copilot/settings.json`.
-- Claude Opus 4.6, Claude Haiku 4.5 (Anthropic)
-- GPT-5.3-Codex (OpenAI)
-- Gemini 3 Pro (Google)
+To set `auto` as the default model, exec into the container and create the file:
-Model selection is controlled by Copilot CLI itself (via `/model` in interactive mode). In ACP mode, the default model is used.
+```bash
+kubectl exec -it deployment/openab-copilot-copilot -- bash -c '
+cat << EOF > ~/.copilot/settings.json
+{
+ "model": "auto"
+}
+EOF'
+```
+
+The `auto` setting lets Copilot automatically select the best model for each request. This persists across pod restarts when `persistence.enabled=true` (the home directory is on a PVC).
## Known Limitations
diff --git a/docs/cronjob.md b/docs/cronjob.md
index 61e7dc1be..4591af29c 100644
--- a/docs/cronjob.md
+++ b/docs/cronjob.md
@@ -179,7 +179,7 @@ The path is relative to `$HOME/.openab/` (e.g. `"cronjob.toml"` resolves to `$HO
> **New installations**: If `~/.openab/` does not exist yet, the scheduler silently skips the file and continues running. Once you create the directory and place `cronjob.toml` inside, it will be picked up automatically on the next tick — no restart required.
> [!CAUTION]
-> **Breaking Change** — `usercron_path` relative path base changed from `$HOME` to `$HOME/.openab/`.
+> **Breaking Change (v0.8.2)** — `usercron_path` relative path base changed from `$HOME` to `$HOME/.openab/`.
> If you are upgrading from a previous version, move your existing file:
> ```bash
> mkdir -p ~/.openab
@@ -256,6 +256,44 @@ Agent: ✅ Written to cronjob.toml, takes effect within 1 minute
This enables mobile-friendly schedule management — talk to your agent from your phone, and it updates the cron file for you.
+### Goal-Driven Auto-Disable
+
+Usercron jobs can stop themselves once a goal is complete. Add `disable_on_success` to run a command before the scheduled prompt is sent. The job is considered complete only when the command exits `0` **and** stdout or stderr contains `disable_on_success_match`.
+
+```toml
+[[jobs]]
+id = "fix-unit-tests" # required for scheduler writeback
+enabled = true
+schedule = "*/10 * * * *"
+channel = "1490282656913559673"
+message = "Unit tests are still failing. Continue fixing them and report progress."
+
+disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS"
+disable_on_success_match = "OPENAB_GOAL_SUCCESS"
+disable_on_success_timeout_secs = 120
+disable_on_success_working_dir = "/workspace/my-project"
+```
+
+Execution flow:
+
+1. The schedule matches.
+2. The scheduler runs `disable_on_success`.
+3. If the command exits `0` and output contains `disable_on_success_match`, OpenAB posts `✅ Goal achieved`, writes `enabled = false` back to `$HOME/.openab/cronjob.toml`, and skips the regular prompt.
+4. Otherwise, OpenAB sends the regular `message` and the agent continues working.
+
+`disable_on_success` is supported only in usercron `[[jobs]]`, not baseline `[[cron.jobs]]`. This keeps scheduler writeback limited to the user-managed cron file.
+
+### Re-enabling a Disabled Job
+
+Once a goal is achieved and the job is disabled, re-enable it by editing `$HOME/.openab/cronjob.toml`:
+
+```toml
+# Flip back to true to restart the job
+enabled = true
+```
+
+This can be done manually, or by asking the AI agent (e.g. "re-enable the fix-unit-tests cron job").
+
### Kubernetes Deployment
Mount `cronjob.toml` on a PVC so it persists across pod restarts, and set `usercron_path` in your config.toml:
@@ -273,7 +311,7 @@ usercron_path = "cronjob.toml"
- **Minute-aligned**: The scheduler aligns to minute boundaries (`:00`), so `0 9 * * *` fires at exactly 09:00:00, not at whatever second the process started.
- **Overlap protection**: If a previous execution of the same job is still running, the next tick is skipped.
- **Isolation**: Cron failures are logged but never block interactive chat traffic.
-- **Stateless**: No persistence needed. Schedules are re-evaluated from config on restart.
+- **Usercron persistence**: For usercron jobs, the scheduler may write `thread_id` and `enabled = false` back to `cronjob.toml`.
- **Graceful shutdown**: In-flight cron tasks are waited on (up to 30 seconds) during shutdown.
## Sender Identity
@@ -300,6 +338,15 @@ Config-driven cron covers the 80% use case: "send this message at this time." Fo
See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the external scheduler approach.
+## Known Limitations
+
+| Limitation | Details |
+|---|---|
+| Mixed numeric/name day-of-week | `1,Mon` or `Mon,3` is not supported and will be rejected. Use either all numeric (`1-5`) or all name-based (`Mon-Fri`) notation. |
+| Wrap-around day-of-week ranges | `5-2` (Fri through Tue) is not supported. Use explicit listing instead: `5,6,0,1,2`. |
+
+> **Tip:** Name-based notation (`Mon-Fri`, `Sun`, `Mon,Wed,Fri`) is always available as an alternative to numeric day-of-week values.
+
## Troubleshooting
| Symptom | Cause | Fix |
@@ -311,3 +358,4 @@ See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the
| Channel not found | Bot not in channel | Invite the bot to the target channel |
| Usercron not reloading | File not saved / wrong path | Check logs for `usercron file changed, reloading` |
| Usercron parse error | Invalid TOML syntax | Check logs for `failed to parse usercron file` |
+| Goal job does not auto-disable | Command did not exit `0` or output did not include `disable_on_success_match` | Run the command manually and confirm both conditions |
diff --git a/docs/discord.md b/docs/discord.md
index 9a3527962..0cabd6451 100644
--- a/docs/discord.md
+++ b/docs/discord.md
@@ -134,18 +134,53 @@ trusted_bot_ids = ["123456789012345678"] # only this bot's messages pass throug
Empty (default) = any bot can pass through (subject to the mode check).
+### `allowed_role_ids`
+
+Role IDs that trigger the bot, same as a direct @mention. This enables users to invoke multiple bots simultaneously with a single role mention (e.g. `@AllBots review this`).
+
+```toml
+allowed_role_ids = ["123456789012345678"] # @mention this role = trigger the bot
+```
+
+Empty (default) = role mentions do not trigger the bot.
+
+**Setup:**
+1. Create a Discord role (e.g. `Bots` or `AllAgents`)
+2. Assign the role to all bots you want to trigger together
+3. Add the role's ID to each bot's `allowed_role_ids`
+4. Users type `@RoleName ` to trigger all bots at once
+
+> **Note:** If multiple bots share the same role, all will respond simultaneously. Use `multibot-mentions` mode if you want bots to require explicit @mention when other bots are already in the thread.
+
+#### Interaction with `multibot-mentions` mode
+
+When `allow_user_messages = "multibot-mentions"` is set alongside `allowed_role_ids`:
+
+| Action | Result |
+|--------|--------|
+| `@Role review this` in a channel | All bots trigger (role mention = explicit mention) |
+| Follow-up in the thread without @mention | Only the thread owner responds (multibot gate kicks in) |
+| `@Role follow up` in the thread | All bots respond again |
+
+This gives the best of both worlds: one role mention to summon all bots, but subsequent messages in the thread don't cause all bots to pile on.
+
---
## @Mention Behavior
-**Always @mention the bot user, not the role.** Discord shows both in autocomplete — pick the one without the role icon.
+The bot responds to:
+
+1. **Direct @mention** (`@BotUser`) — always works
+2. **Role mention** (`@RoleName`) — only if the role ID is in `allowed_role_ids`
+3. **Thread reply** — depends on `allow_user_messages` mode (no @mention needed in `involved` mode)
```
-✅ @AgentBroker hello ← user mention, bot responds
-❌ @AgentBroker hello ← role mention (with role icon), bot ignores
+✅ @AgentBroker hello ← user mention, bot responds
+✅ @AllBots hello ← role mention, bot responds (if role in allowed_role_ids)
+❌ @SomeOtherRole hello ← role not in allowed_role_ids, bot ignores
```
-Role mentions are ignored because they are shared across bots and cause false positives in multi-bot setups. This is intentional since v0.7.8-beta.3 (#420, #440).
+The triggering role mention is stripped from the prompt sent to the agent (same as the bot's own user mention).
### User mention UIDs
@@ -153,7 +188,8 @@ When a user mentions another user (e.g. `@SomeUser`) in a message to the bot, th
- The LLM can copy `<@UID>` into its reply to produce a clickable Discord mention
- The bot's own mention is stripped (so the bot doesn't see itself being triggered)
-- Role mentions are replaced with `@(role)` placeholder
+- Triggering role mentions (in `allowed_role_ids`) are stripped
+- Other role mentions are replaced with `@(role)` placeholder
To help the LLM know who each UID refers to, provide a UID→name mapping via system prompt or context entry (see [Multi-Bot Setup](#multi-bot-setup) below).
@@ -170,6 +206,37 @@ Each thread gets its own agent session. Sessions are cleaned up after `session_t
---
+## Attachment Handling
+
+OpenAB processes Discord file attachments and converts them into content blocks
+for the agent. Supported types (checked in order):
+
+| Type | Detection | Agent receives |
+|------|-----------|----------------|
+| Audio | MIME `audio/*` | Transcribed text via STT (if enabled) |
+| Text files | Extension list (`.txt`, `.md`, `.json`, etc.) | File content inlined (up to 5 files, 1 MB total) |
+| Images | MIME `image/*` or image extensions | Base64-encoded image block |
+| Video | MIME `video/*` or extensions (`.mp4`, `.mov`, `.webm`, `.mkv`, `.m4v`, `.avi`) | Text block with filename, content type, size, and Discord CDN URL |
+
+Unsupported attachment types are silently ignored.
+
+### Video attachments
+
+Video files are not downloaded or transcoded. The agent receives metadata and the
+Discord CDN URL so it can fetch or inspect the file using tools like `ffprobe`.
+
+```
+[Video attachment]
+filename: demo.mp4
+content_type: video/mp4
+size_bytes: 8421376
+url: https://cdn.discordapp.com/attachments/.../demo.mp4
+```
+
+No configuration is needed — video forwarding is always enabled.
+
+---
+
## Streaming
OpenAB uses **edit-streaming** on Discord — the bot sends a placeholder message and updates it every 1.5 seconds as tokens arrive, giving a live typing effect.
@@ -274,10 +341,11 @@ helm install openab openab/openab \
--set agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \
--set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \
--set agents.kiro.discord.allowBotMessages=off \
- --set agents.kiro.discord.allowUserMessages=involved
+ --set agents.kiro.discord.allowUserMessages=involved \
+ --set-string 'agents.kiro.discord.allowedRoleIds[0]=YOUR_ROLE_ID'
```
-⚠️ Use `--set-string` for channel/user IDs to avoid float64 precision loss.
+⚠️ Use `--set-string` for channel/user/role IDs to avoid float64 precision loss.
---
@@ -288,7 +356,7 @@ helm install openab openab/openab \
1. **Check channel ID** — make sure it's in `allowed_channels`
2. **Check permissions** — bot needs Send Messages, Create Public Threads, Read Message History in the channel
3. **Check intents** — Message Content Intent must be enabled in Developer Portal
-4. **Check @mention type** — use user mention, not role mention
+4. **Check @mention type** — use user mention or a role in `allowed_role_ids`
5. **Check if in a thread** — with `mentions` mode, @mention is required even in threads
### Bot stops receiving messages after restart
diff --git a/docs/feishu.md b/docs/feishu.md
index a4a494f98..15e344f12 100644
--- a/docs/feishu.md
+++ b/docs/feishu.md
@@ -80,6 +80,8 @@ https://your-gateway-host/webhook/feishu
| — | `FEISHU_ALLOW_BOTS` | `off` | Bot message handling: `off` / `mentions` / `all` |
| — | `FEISHU_TRUSTED_BOT_IDS` | — | Comma-separated open_id list of known bots |
| — | `FEISHU_MAX_BOT_TURNS` | `20` | Max consecutive bot replies per channel before suppression |
+| — | `FEISHU_SESSION_TTL_HOURS` | `24` | How long the bot remembers thread participation (hours). After expiry, @mention is required again. |
+| — | `FEISHU_ALLOW_USER_MESSAGES` | `involved` | Thread response mode: `involved` / `mentions` / `multibot-mentions`. See below. |
| `gateway.botUsername` | — | — | Set to bot's `open_id` for @mention gating |
| `gateway.streaming` | — | `false` | Enable streaming (typewriter) mode |
@@ -95,6 +97,32 @@ In group chats, the bot only responds when @mentioned (default). To find your bo
To disable mention gating: `feishu.requireMention: false`.
+### Thread Participation (Involved Mode)
+
+Once the bot replies in a thread (topic), it remembers that thread and responds to subsequent messages **without requiring @mention** — similar to Discord's `allow_user_messages: "involved"` mode.
+
+- Only applies to threads (messages with `root_id`). Main channel messages always require @mention.
+- Participation is stored in memory. Gateway restart clears the cache; users need to @mention once to re-engage.
+- TTL controlled by `FEISHU_SESSION_TTL_HOURS` (default 24h). After expiry, @mention is required again.
+
+### Multi-Bot Threads (multibot-mentions Mode)
+
+When `FEISHU_ALLOW_USER_MESSAGES=multibot-mentions`, the bot detects when another bot is @mentioned in a participated thread and reverts to requiring @mention — preventing all bots from responding simultaneously.
+
+| Mode | Behavior |
+|------|----------|
+| `involved` (default) | Bot responds in participated threads without @mention. All participated bots respond. |
+| `multibot-mentions` | Same as `involved`, but once another bot is @mentioned in the thread, require @mention for all bots. |
+| `mentions` | Always require @mention, even in participated threads. |
+
+**Multi-bot detection** (how the gateway identifies "another bot"):
+
+1. If `FEISHU_TRUSTED_BOT_IDS` is set → exact match against configured IDs
+2. If only `FEISHU_ALLOWED_USERS` is set → any @mention that is not self and not in allowed_users is inferred as another bot (recommended, zero-config)
+3. If neither is set → no multibot detection
+
+Note: Detection only triggers in threads where the bot has already participated. This prevents premature marking of threads the bot hasn't joined.
+
## Security Notes
- `appSecret`, `verificationToken`, and `encryptKey` are stored in a Kubernetes Secret, not in ConfigMap.
@@ -137,13 +165,14 @@ The gateway downloads and forwards image and text file attachments to the AI age
| Feishu msg_type | Handling |
|-----------------|----------|
| `text` | Text extracted, forwarded as prompt |
-| `image` | Image downloaded, resized (max 1200px), JPEG compressed, base64 encoded → `ContentBlock::Image` |
+| `image` | Image downloaded, resized (max 1200px), JPEG compressed, stored to `~/.openab/media/inbound/` → `ContentBlock::Image` |
| `file` | Text files only (`.txt`, `.py`, `.rs`, `.md`, `.json`, etc., max 512KB). Non-text files (`.pdf`, `.zip`, etc.) are silently ignored. |
+| `audio` | Voice message downloaded (opus/ogg, max 25MB), stored to filesystem, forwarded to core. If `[stt]` is enabled, core transcribes via Whisper API and injects `[Voice message transcript]: ...` into the prompt. If STT is disabled or fails, the message is silently skipped. |
| `post` | Rich text: text nodes extracted as prompt, `img` nodes downloaded as image attachments. This is the format Feishu uses when @mention + paste image in a group. |
**Group chat limitation:** Feishu does not allow @mention and image upload in the same message. However, @mention + paste (Ctrl+V) an image works — Feishu sends this as a `post` message containing both the mention and the image. Direct image upload (via the attachment button) cannot include @mention, so the bot will not respond in groups.
-**Processing pipeline:** Gateway downloads media using `GET /im/v1/messages/{message_id}/resources/{key}?type=image` with `tenant_access_token`, resizes to max 1200px, compresses to JPEG (quality 75), base64 encodes, and embeds in the `GatewayEvent.content.attachments` field. OAB core decodes attachments into `ContentBlock::Image` or `ContentBlock::Text` for the AI agent.
+**Processing pipeline:** Gateway downloads media using `GET /im/v1/messages/{message_id}/resources/{key}?type=image` with `tenant_access_token`, resizes to max 1200px, compresses to JPEG (quality 75), and stores to `~/.openab/media/inbound/`. The file path is passed in `GatewayEvent.content.attachments[].path`. OAB core reads the file directly from disk and converts to `ContentBlock::Image` or `ContentBlock::Text` for the AI agent.
## Streaming (Typewriter)
@@ -172,6 +201,22 @@ To start a threaded conversation: reply to any bot message in a group chat (long
Streaming (typewriter) mode works in threads — edits target the same message regardless of thread context.
+## Agent-Controlled Reply-To
+
+Agents can reply to a specific message using the `[[reply_to:message_id]]` output directive (see [docs/output-directives.md](output-directives.md)). The gateway sends the reply via Feishu's native Reply API, showing a quote reference in the UI.
+
+```
+Agent output:
+ [[reply_to:om_xxx]]
+ This is my reply to that specific message.
+```
+
+**How agents get message IDs:** Every incoming message includes `message_id` in the `SenderContext` injected into the agent prompt. Agents can store and reference these IDs to reply to specific messages.
+
+**Fallback:** If the specified message ID is invalid or the Reply API fails, the gateway automatically falls back to a plain send (no quote).
+
+**Use case:** In multi-bot threads, each bot can reply to a different message, creating clear visual conversation threads within a Feishu thread.
+
## Bot-to-Bot Collaboration (Gateway-Side Only)
The gateway adapter includes bot identification and filtering scaffolding (`AllowBots` enum, `FEISHU_TRUSTED_BOT_IDS`, `FEISHU_MAX_BOT_TURNS` with human-reset safety valve), matching Discord's `allow_bot_messages` design.
diff --git a/docs/google-chat.md b/docs/google-chat.md
index d51253c93..584ea450e 100644
--- a/docs/google-chat.md
+++ b/docs/google-chat.md
@@ -143,11 +143,17 @@ working_dir = "/home/agent"
- Inline code, fenced code blocks: pass through unchanged
- Tables and other unsupported syntax pass through as-is
- **Streaming (edit_message)** — when OAB streaming is enabled, the bot edits its initial reply in-place as tokens arrive (typewriter effect)
+- **Inbound attachments** — image, text file, and audio attachments are downloaded via Google Chat Media API and stored to `~/.openab/media/inbound/` (colocate filesystem store):
+ - Images: resized to ≤1200px JPEG (q75); GIFs preserved. Max 10 MB.
+ - Text files: only known text extensions (`.txt`, `.md`, `.json`, `.py`, `.rs`, etc.). Max 512 KB.
+ - Audio: forwarded as-is for STT processing by core. Max 25 MB.
+ - Drive-sourced attachments are skipped (require separate Drive API integration).
### Not Supported
- **Reactions** — Google Chat API does not support message reactions on behalf of bots
-- **File/image attachments** — not yet implemented
+- **Outbound attachments** — bot cannot send image/file attachments back to the user yet
+- **Drive-linked attachments** — only `UPLOADED_CONTENT` source is handled; `DRIVE_FILE` source skipped
## Environment Variables (Gateway)
diff --git a/docs/grok.md b/docs/grok.md
new file mode 100644
index 000000000..b6d4d09c0
--- /dev/null
+++ b/docs/grok.md
@@ -0,0 +1,134 @@
+# Grok Build (xAI)
+
+[Grok Build](https://x.ai/news/grok-build-cli) is xAI's official coding agent CLI. It speaks ACP natively via `grok agent stdio` — no wrapper required.
+
+## Docker Image
+
+```bash
+docker build -f Dockerfile.grok -t openab-grok:latest .
+```
+
+The image pulls a pinned `grok` binary from xAI's public artifacts bucket and verifies its SHA256 checksum. Bump `GROK_VERSION`, `GROK_SHA256_AMD64`, and `GROK_SHA256_ARM64` in `Dockerfile.grok` to upgrade.
+
+## Helm Install
+
+```bash
+helm install openab openab/openab \
+ --set agents.kiro.enabled=false \
+ --set agents.grok.discord.enabled=true \
+ --set agents.grok.discord.botToken="$DISCORD_BOT_TOKEN" \
+ --set-string 'agents.grok.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \
+ --set agents.grok.image=ghcr.io/openabdev/openab-grok:latest \
+ --set agents.grok.command=grok \
+ --set-string 'agents.grok.args[0]=agent' \
+ --set-string 'agents.grok.args[1]=stdio' \
+ --set agents.grok.workingDir=/home/agent
+```
+
+> Set `agents.kiro.enabled=false` to disable the default Kiro agent.
+
+## Manual config.toml
+
+```toml
+[agent]
+command = "grok"
+args = ["agent", "stdio"]
+working_dir = "/home/agent"
+```
+
+## Authentication
+
+Grok Build supports three credential sources. Pick whichever fits your deployment.
+
+### Option A: API key (simplest, recommended for CI / bot deployments)
+
+Set the environment variable in the pod / task definition:
+
+```bash
+export GROK_CODE_XAI_API_KEY="xai-..."
+```
+
+Get a key from . No interactive login needed.
+
+> ⚠️ **Security**: env vars listed under `[agent].env` are visible to the agent and can be leaked via prompt injection. Prefer mounting them via the platform's secret manager.
+
+### Option B: Device-code OAuth (for SuperGrok subscriptions)
+
+If you want to use a SuperGrok subscription instead of pay-per-token API billing:
+
+```bash
+kubectl exec -it -- grok login --device-auth
+```
+
+The CLI prints a short code and URL — open the URL on any device, enter the code, approve. The token is stored at `~/.grok/auth.json` inside the container.
+
+This works in any headless environment (K8s exec, ECS exec, plain SSH) **without port-forwarding** — unlike loopback OAuth flows.
+
+### Option C: Enterprise deployment key
+
+```bash
+export GROK_DEPLOYMENT_KEY="..."
+```
+
+A deployment key takes precedence over `auth.json`. The CLI fetches managed config from `cli-chat-proxy.grok.com/v1/deployment/config` on startup. Available to xAI enterprise customers; contact xAI sales for details.
+
+## Credential Persistence
+
+`grok login` stores OAuth credentials at `~/.grok/auth.json` and runtime config at `~/.grok/config.toml`. The OpenAB Helm chart's default persistence covers `workingDir` automatically (PVC mounted at `/home/agent`).
+
+If deploying manually, mount persistent storage at `/home/agent/.grok`:
+
+```yaml
+volumes:
+ - name: grok-credentials
+ persistentVolumeClaim:
+ claimName: grok-credentials-pvc
+volumeMounts:
+ - name: grok-credentials
+ mountPath: /home/agent/.grok
+```
+
+API-key-only deployments don't need persistence.
+
+## Model Selection
+
+The default model is whichever Grok Build CLI selects (currently `grok-code-fast-1` for the free tier; `grok-4.3` family for SuperGrok). To override:
+
+```toml
+[agent]
+command = "grok"
+args = ["agent", "stdio", "--model", "grok-4.3"]
+working_dir = "/home/agent"
+```
+
+List available models inside the pod:
+
+```bash
+kubectl exec -it -- grok models
+```
+
+## Updating
+
+```bash
+# Inside the container (one-shot upgrade):
+kubectl exec -it -- grok update
+
+# Or rebuild the image with a new pinned version:
+docker build -f Dockerfile.grok \
+ --build-arg GROK_VERSION=0.1.220 \
+ --build-arg GROK_SHA256_AMD64=... \
+ --build-arg GROK_SHA256_ARM64=... \
+ -t openab-grok:latest .
+```
+
+## Comparison with Hermes
+
+| Property | `Dockerfile.grok` | `Dockerfile.hermes` |
+|----------|-------------------|---------------------|
+| Provider | xAI Grok only | xAI + 30 others via Nous gateway |
+| ACP | Native (`grok agent stdio`) | Via `hermes-acp` wrapper |
+| Headless auth | API key env or device-code | Loopback OAuth (needs port-forward / ECS curl trick) |
+| Supply chain | xAI only | xAI + Nous Research install script |
+| Image size | Smaller (single static binary, no Python venv) | Larger (Python + uv + ffmpeg) |
+
+Pick `Dockerfile.grok` if Grok is the only model you need. Pick `Dockerfile.hermes` if you want multi-provider switching or fallback chains.
diff --git a/docs/hermes.md b/docs/hermes.md
new file mode 100644
index 000000000..d5cccc7ba
--- /dev/null
+++ b/docs/hermes.md
@@ -0,0 +1,176 @@
+# Hermes Agent
+
+[Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research supports ACP natively via the `hermes acp` subcommand (or the `hermes-acp` binary).
+
+Hermes acts as a multi-provider inference gateway — it handles OAuth token lifecycle, credential storage, and provider routing so OAB agents don't need to manage auth directly.
+
+## Docker Image
+
+```bash
+docker build -f Dockerfile.hermes -t openab-hermes:latest .
+```
+
+The image installs Hermes Agent via the official install script.
+
+## Helm Install
+
+```bash
+helm install openab openab/openab \
+ --set agents.kiro.enabled=false \
+ --set agents.hermes.discord.enabled=true \
+ --set agents.hermes.discord.botToken="$DISCORD_BOT_TOKEN" \
+ --set-string 'agents.hermes.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \
+ --set agents.hermes.image=ghcr.io/openabdev/openab-hermes:latest \
+ --set agents.hermes.command=hermes-acp \
+ --set agents.hermes.workingDir=/home/agent
+```
+
+> Set `agents.kiro.enabled=false` to disable the default Kiro agent.
+
+## Manual config.toml
+
+```toml
+[agent]
+command = "hermes-acp"
+working_dir = "/home/agent"
+```
+
+## Authentication
+
+Hermes supports 30+ providers. Authenticate inside the pod:
+
+```bash
+kubectl exec -it -- hermes auth add xai-oauth # xAI Grok (SuperGrok $30/mo)
+kubectl exec -it -- hermes auth add nous # Nous Portal
+kubectl exec -it -- hermes model # Interactive provider picker
+```
+
+### xAI Grok OAuth (Recommended)
+
+> ⚠️ **Requires an active [SuperGrok paid subscription](https://x.ai/grok) ($30/mo).** Auth will succeed without one, but the API silently returns empty responses — the bot appears to work but never replies.
+
+xAI Grok OAuth uses a loopback redirect flow — the callback listener binds `127.0.0.1:56121` inside the pod/container.
+
+#### Option A: Kubernetes (port-forward)
+
+```bash
+# Terminal 1: port-forward
+kubectl port-forward deployment/ 56121:56121
+
+# Terminal 2: run auth
+kubectl exec -it deployment/ -- hermes auth add xai-oauth --no-browser
+```
+
+1. Copy the printed authorize URL → open in your local browser
+2. Approve access on accounts.x.ai
+3. Browser redirects to `127.0.0.1:56121/callback` → port-forward delivers it to the pod
+4. Terminal shows `Added xai-oauth OAuth credential #1: "xai-oauth-oauth-1"`
+
+#### Option B: ECS / Remote (curl-the-callback)
+
+ECS Fargate doesn't support port-forward. Use two exec sessions instead:
+
+```bash
+# Terminal 1: start the auth listener
+aws ecs execute-command --cluster openab --task --container openab --interactive --command bash
+hermes auth add xai-oauth --no-browser
+# → prints authorize URL with &state=XXXXX in it
+# → "Waiting for callback on http://127.0.0.1:56121/callback"
+```
+
+Open the authorize URL in your browser and approve. The browser will redirect to
+`http://127.0.0.1:56121/callback?code=...` and fail ("Could not establish connection").
+**Copy the `code` value** from the page or URL bar. The `state` value comes from the
+authorize URL printed in Terminal 1.
+
+```bash
+# Terminal 2: exec into the SAME container
+aws ecs execute-command --cluster openab --task --container openab --interactive --command bash
+curl "http://127.0.0.1:56121/callback?code=&state="
+```
+
+Terminal 1 should print:
+```
+Added xai-oauth OAuth credential #1: "xai-oauth-oauth-1"
+```
+
+> ⚠️ The code expires in seconds — be fast. If you get `invalid_grant`, re-run `hermes auth add` and try again.
+
+#### After auth: set the default model
+
+```bash
+hermes config set model.provider xai-oauth
+hermes config set model.default grok-4.3
+```
+
+#### Fix file ownership (important for exec-based auth)
+
+When running auth/config commands via `kubectl exec` or ECS exec (which runs as root),
+fix ownership so the `agent` user can read the files:
+
+```bash
+chown -R agent:agent /home/agent/.hermes/
+```
+
+### Providers That Don't Need Port-Forward
+
+| Provider | Auth Method |
+|----------|-------------|
+| Anthropic (Claude Pro/Max) | Paste-the-code flow |
+| OpenAI Codex (ChatGPT Plus/Pro) | Device code flow |
+| MiniMax, Nous Portal | Device code flow |
+| xAI Grok, Spotify | Loopback OAuth (port-forward required) |
+
+### Supported Providers (via OAuth)
+
+| Provider | Auth Command | Cost Model |
+|----------|-------------|------------|
+| xAI Grok | `hermes auth add xai-oauth` | SuperGrok subscription ($30/mo) |
+| OpenAI Codex | `hermes model` → OpenAI Codex | ChatGPT subscription |
+| GitHub Copilot | `hermes model` → GitHub Copilot | Copilot subscription |
+| Google Gemini | `hermes model` → Google Gemini (OAuth) | Free tier available |
+| Anthropic | `hermes model` → Anthropic | Claude Max + extra credits |
+| Nous Portal | `hermes auth add nous` | Nous subscription |
+
+### Supported Providers (via API Key)
+
+Any provider can also be configured with an API key via environment variables:
+
+```toml
+[agent]
+command = "hermes-acp"
+working_dir = "/home/agent"
+env = { XAI_API_KEY = "${XAI_API_KEY}" }
+```
+
+## Provider Switching
+
+Switch providers without restarting the pod:
+
+```bash
+kubectl exec -it -- hermes model
+```
+
+## Credential Persistence
+
+Hermes stores OAuth tokens in `~/.hermes/`. The OpenAB Helm chart's default persistence covers this automatically (PVC mounted at `workingDir`).
+
+If deploying manually (without the Helm chart), mount persistent storage at `/home/agent` or `/home/agent/.hermes`:
+
+```yaml
+volumes:
+ - name: hermes-credentials
+ persistentVolumeClaim:
+ claimName: hermes-credentials-pvc
+volumeMounts:
+ - name: hermes-credentials
+ mountPath: /home/agent/.hermes
+```
+
+## Advantages
+
+- **Cost**: SuperGrok $30/mo flat rate vs pay-per-token API pricing
+- **Multi-provider**: 30+ providers accessible through one agent
+- **Zero auth complexity**: Hermes handles OAuth + token refresh
+- **Multi-modal**: TTS, image gen, video gen via the same OAuth token
+- **Fallback chains**: Auto-switch providers on failure
diff --git a/docs/image-tags.md b/docs/image-tags.md
new file mode 100644
index 000000000..ab533dc18
--- /dev/null
+++ b/docs/image-tags.md
@@ -0,0 +1,55 @@
+# Docker Image Tagging Convention
+
+## Core (`ghcr.io/openabdev/openab`)
+
+| Tag | Points to | Updated when |
+|-----|-----------|--------------|
+| `0.8.3-beta.12` | Exact pre-release build | Pre-release tag pushed |
+| `beta` | Latest pre-release | Every pre-release build |
+| `0.8.3` | Promoted stable build | Stable tag pushed |
+| `0.8` | Latest patch in minor | Stable promotion |
+| `stable` | Latest stable | Stable promotion |
+| `latest` | Latest stable (= `stable`) | Stable promotion |
+
+Variant images (e.g. `-codex`, `-claude`, `-gemini`) follow the same convention with a suffix: `ghcr.io/openabdev/openab-codex:beta`.
+
+## Gateway (`ghcr.io/openabdev/openab-gateway`)
+
+| Tag | Points to | Updated when |
+|-----|-----------|--------------|
+| `0.5.1` | Exact release | `gateway-v*` tag pushed |
+| `v0.5.1` | Same as above (v-prefixed alias) | Same |
+| `latest` | Latest release | Every release |
+
+## Which tag to use
+
+| Use case | Recommended tag |
+|----------|----------------|
+| Production (pinned) | Exact version (`0.8.3-beta.12`) |
+| Helm chart default | `stable` or `beta` (channel-based) |
+| Local dev / quick test | `beta` |
+| CI | Exact version or SHA |
+
+## Release flow
+
+```
+release PR merged → tag-on-merge → v0.8.3-beta.12
+ │
+ ▼
+ build-operator.yml
+ │
+ ┌──────────┴──────────┐
+ │ is_prerelease=true │
+ ▼ │
+ tag: 0.8.3-beta.12 │
+ tag: beta │
+ │
+ ┌──────────────────────┘
+ │ is_prerelease=false (stable)
+ ▼
+ promote latest beta image →
+ tag: 0.8.3
+ tag: 0.8
+ tag: stable
+ tag: latest
+```
diff --git a/docs/inbound-attachments.md b/docs/inbound-attachments.md
new file mode 100644
index 000000000..47f3136bb
--- /dev/null
+++ b/docs/inbound-attachments.md
@@ -0,0 +1,100 @@
+# Inbound Attachments
+
+How OAB handles images, audio, and files sent by users across all platforms.
+
+## Architecture
+
+```
+User sends media (photo/voice/file)
+ → Platform webhook delivers to Gateway
+ → Gateway downloads via platform API (auth stays in Gateway)
+ → Image: resize ≤1200px, JPEG compress (GIF passthrough ≤5MB)
+ → Store to ~/.openab/media/inbound/
+ → WS event includes file path in attachments[].path
+ → Core reads from disk (zero encoding overhead)
+ → Processes: image → LLM, audio → STT, text_file → code block
+ → File auto-evicted after 2 minutes
+```
+
+## Platform Support Matrix
+
+| Platform | Images | Audio/Voice | Text Files | Video | Binary Files |
+|----------|--------|-------------|------------|-------|--------------|
+| **Discord** | ✅ | ✅ (STT) | ✅ | metadata only | skipped |
+| **Telegram** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | skipped |
+| **Feishu** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | skipped |
+| **Google Chat** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | Drive files skipped |
+| **WeCom** | ✅ | — | ✅ (whitelist) | skipped | skipped |
+| **LINE** | planned | planned | — | — | — |
+| **Slack** | ✅ | ✅ (STT) | ✅ | — | skipped |
+
+## Processing Pipeline
+
+### Images
+
+1. Gateway downloads from platform API
+2. `resize_and_compress()` — longest side ≤1200px, JPEG quality 75
+3. GIFs ≤5MB passed through unchanged (preserves animation)
+4. Stored to `~/.openab/media/inbound/`
+5. Core reads bytes → `ContentBlock::Image` → sent to LLM
+
+### Audio / Voice Messages
+
+1. Gateway downloads raw audio (ogg/m4a/mp3)
+2. Stored to filesystem (no transcoding)
+3. Core reads bytes → STT transcription (Whisper/Groq) → `[Voice message transcript]: ...`
+4. If STT disabled: silently skipped
+
+### Text Files (Documents)
+
+1. Gateway downloads file
+2. Extension whitelist check: `.txt`, `.csv`, `.md`, `.json`, `.yaml`, `.rs`, `.py`, `.js`, `.ts`, `.go`, `.java`, `.c`, `.cpp`, `.sh`, `.sql`, `.html`, `.css`, `.toml`, `.xml`, `.ini`, `.cfg`, `.conf`, etc.
+3. UTF-8 validation — non-UTF-8 files rejected
+4. Stored to filesystem
+5. Core reads → wraps in markdown code block: `` ```filename.ext\n\n``` ``
+
+### Unsupported Types
+
+Binary files (zip, pdf, exe, docx), video, and stickers are **silently skipped**. The agent does not receive any notification that a file was sent.
+
+## Size Limits
+
+| Type | Max Size | Enforced By |
+|------|----------|-------------|
+| Images | 10 MB | Gateway (pre-download Content-Length + post-download bytes) |
+| Audio | 20 MB | Gateway |
+| Text files | 20 MB | Gateway (same as store cap) |
+| GIF passthrough | 5 MB | `resize_and_compress()` |
+| Store (defense-in-depth) | 20 MB | `store_media()` |
+
+## Storage (Colocate Mode)
+
+Media is stored at `~/.openab/media/inbound/`:
+
+- **Filenames**: Server-generated UUID v4, no extension (MIME type in event payload)
+- **TTL**: 2 minutes — background task evicts expired files every 30 seconds
+- **Trust boundary**: Gateway and Core share the same `$HOME` (same pod / sidecar)
+- **No auth required**: Core reads directly from filesystem, no HTTP/token needed
+
+### Security
+
+- **Path traversal**: Impossible — filenames are UUID only, never user-supplied
+- **Token leakage**: Platform auth tokens (Telegram bot token, LINE access token, Feishu tenant token) stay in Gateway, never reach Core or agent
+- **Disk exhaustion**: TTL eviction + size limits prevent unbounded growth
+- **No executable content**: Files are raw data, never executed
+
+### Future: HTTP Proxy Mode
+
+For separated deployments (Gateway ≠ Core pod), a future PR will add `GET /media/` on the Gateway, allowing Core to fetch via internal HTTP. The `attachments[].path` field will be replaced by `attachments[].url` in that mode.
+
+## Configuration
+
+No additional configuration required. The filesystem store is always active when Gateway is running. Ensure Gateway and Core share the same `$HOME` (default in Helm colocate/sidecar mode).
+
+## Related
+
+- [Telegram](telegram.md) — Telegram-specific behavior and limitations
+- [Feishu](feishu.md) — Feishu image/file/audio handling
+- [Google Chat](google-chat.md) — Google Chat attachment support
+- [STT (Speech-to-Text)](stt.md) — Audio transcription configuration
+- [Sending Files (Outbound)](sendfiles.md) — Agent → user file delivery (separate mechanism)
diff --git a/docs/messaging.md b/docs/messaging.md
index 9172bca0c..40b3273d0 100644
--- a/docs/messaging.md
+++ b/docs/messaging.md
@@ -187,7 +187,7 @@ BotA in thread: here's my analysis
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `allow_bot_messages` | string | `"off"` | `"off"` — ignore bot messages. `"mentions"` — only process bot messages that @mention this bot. `"all"` — process all bot messages (capped by `max_bot_turns`). |
-| `trusted_bot_ids` | string[] | `[]` | Whitelist of bot IDs. When non-empty, only these bots pass the bot gate. Empty = any bot (mode permitting). Ignored when `allow_bot_messages = "off"`. |
+| `trusted_bot_ids` | string[] | `[]` | Whitelist of bot IDs. For Slack, entries may be Bot User IDs (`U...`) or Bot IDs (`B...`); `U...` matching requires `users:read` so OpenAB can call `bots.info`. Empty = any bot (mode permitting). Ignored when `allow_bot_messages = "off"`. |
| `max_bot_turns` | u32 | `20` | Max consecutive bot turns per thread before throttling. A human message resets the counter. |
> **Safety:** When `allow_bot_messages = "all"`, a separate hardcoded cap of 10 consecutive bot turns applies regardless of `max_bot_turns`.
diff --git a/docs/native-agent.md b/docs/native-agent.md
new file mode 100644
index 000000000..b3baeedfb
--- /dev/null
+++ b/docs/native-agent.md
@@ -0,0 +1,121 @@
+# Native Agent (openab-agent)
+
+A lightweight, native Rust coding agent with built-in ACP support and ChatGPT subscription authentication. No Node.js, no Python, no adapter layer.
+
+## Quick Start
+
+```bash
+# Build
+cd openab-agent && cargo build --release
+
+# Authenticate (browser flow — recommended)
+openab-agent auth codex-oauth
+
+# Headless server (paste callback URL)
+openab-agent auth codex-oauth --no-browser
+
+# Run as ACP server (used by openab core)
+openab-agent
+```
+
+## Configuration
+
+```toml
+[agent]
+command = "openab-agent"
+working_dir = "/home/agent"
+env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" }
+```
+
+## Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `OPENAB_AGENT_OPENAI_MODEL` | `gpt-4.1-nano` | Model to use |
+| `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL |
+| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) |
+| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens |
+| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom OAuth client ID |
+| `ANTHROPIC_API_KEY` | — | Anthropic API key (alternative to OAuth) |
+
+## Authentication
+
+### Browser PKCE Flow (recommended)
+
+```bash
+openab-agent auth codex-oauth
+```
+
+Opens browser to authenticate with your ChatGPT Plus/Pro subscription.
+
+### Headless Server (paste flow)
+
+```bash
+openab-agent auth codex-oauth --no-browser
+```
+
+1. Prints an authorization URL
+2. Open it in any browser and approve
+3. Browser redirects to `localhost:1455` (fails on remote server)
+4. Copy the full URL from the browser address bar
+5. Paste it back into the terminal
+
+### Device Code Flow
+
+```bash
+openab-agent auth codex-device
+```
+
+Note: Device flow currently has limited scopes and may not work with all models.
+
+### API Key (Anthropic)
+
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+```
+
+No login needed — set the env var and the agent auto-detects it.
+
+## Custom System Prompt
+
+Place an `AGENTS.md` file in the working directory (`cwd`). It will be prepended to the default system prompt at session creation.
+
+```
+/home/agent/
+├── AGENTS.md ← read at session start
+├── .openab/
+│ └── agent/
+│ └── auth.json
+└── (your project files)
+```
+
+> **Note:** Skills and MCP servers are NOT supported yet. Only `AGENTS.md` at `cwd` is read. Skills and MCP support are planned for v0.2.
+
+## Docker
+
+```bash
+docker build -f Dockerfile.native -t openab-native:latest .
+```
+
+Image is ~20MB (debian-slim + static Rust binaries). No runtime dependencies.
+
+## Memory Usage
+
+~7MB per session — 28x lighter than Pi, 55x lighter than Kiro CLI.
+
+## Supported Models (ChatGPT Subscription)
+
+- `gpt-5.2`
+- `gpt-5.3-codex`
+- `gpt-5.3-codex-spark`
+- `gpt-5.4`
+- `gpt-5.4-mini`
+- `gpt-5.5`
+
+## Tools
+
+4 built-in tools:
+- `read` — file contents or directory listing
+- `write` — create/overwrite file
+- `edit` — string replacement
+- `bash` — shell execution with process group isolation
diff --git a/docs/opencode.md b/docs/opencode.md
index 97c4117c6..2b65bd826 100644
--- a/docs/opencode.md
+++ b/docs/opencode.md
@@ -116,6 +116,59 @@ kubectl logs deployment/openab-opencode --tail=5
`@mention` the bot in your Discord channel to start chatting.
+## Example: xAI Grok with SuperGrok OAuth
+
+### 1. Create the auth directory
+
+OpenCode stores credentials at `~/.local/share/opencode/auth.json`. The directory must exist before login:
+
+```bash
+kubectl exec deployment/openab-opencode -- mkdir -p /home/node/.local/share/opencode
+```
+
+### 2. Authenticate xAI (device-code flow)
+
+```bash
+kubectl exec -it deployment/openab-opencode -- opencode auth login -p xai
+```
+
+Select **"xAI Grok OAuth (Headless / Remote / VPS)"**. The CLI prints a URL and a short code:
+
+```
+Open https://x.ai/device on any device and enter code: ABCD-1234
+```
+
+Open the URL on any device with a browser, enter the code, and approve.
+
+### 3. Verify auth file was created
+
+```bash
+kubectl exec deployment/openab-opencode -- cat /home/node/.local/share/opencode/auth.json
+```
+
+You should see a JSON object with `xai` credentials.
+
+### 4. Set default model
+
+Create `opencode.json` in the working directory (`/home/node`):
+
+```bash
+kubectl exec -it deployment/openab-opencode -- bash -c 'cat > /home/node/opencode.json << "EOF"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "model": "xai/grok-4.3"
+}
+EOF'
+```
+
+### 5. Restart to pick up config
+
+```bash
+kubectl rollout restart deployment/openab-opencode
+```
+
+> **Important:** Do NOT set a custom `baseURL` or provider override for xAI. The built-in xAI provider handles routing correctly. A stale `~/.config/opencode/opencode.json` with `baseURL: "http://localhost:9090/v1"` (from xai-proxy setups) will break xAI — delete it if present.
+
## Notes
- **Tool authorization**: OpenCode handles tool authorization internally and never emits `session/request_permission` — all tools run without user confirmation, equivalent to `--trust-all-tools` on other backends.
diff --git a/docs/output-directives.md b/docs/output-directives.md
new file mode 100644
index 000000000..9b5876acf
--- /dev/null
+++ b/docs/output-directives.md
@@ -0,0 +1,77 @@
+# Output Directives
+
+## Overview
+
+Agents can control platform-specific message delivery by prefixing their output with `[[key:value]]` directives. OAB parses and strips these before sending to the platform.
+
+## Format
+
+```
+[[reply_to:1502606076451885136]]
+[[ephemeral:true]] ← future
+Actual message content starts here...
+```
+
+Rules:
+- Consecutive `[[key:value]]` lines at the start of output = directive header block
+- First line that doesn't match `[[key:value]]` (with colon) = content begins
+- `[[X]]` without colon is NOT a directive — stops parsing, preserved as content
+- Directives are stripped from the final message (never visible to users)
+- Unknown keys are silently ignored (forward compatible, logged at debug level)
+- If the same key appears multiple times, the last value wins
+
+## Available Directives
+
+### `reply_to`
+
+Reply to a specific message by ID (Discord: `message_reference`).
+
+```
+[[reply_to:1502606076451885136]]
+Here is my reply to that specific message.
+```
+
+**Value**: Platform message ID. Format depends on the target adapter — Discord requires a numeric snowflake; Slack accepts `ts` (e.g. `1234567890.123456`). The directive parser validates that the value is non-empty, ≤64 chars, and contains only ASCII alphanumeric characters plus `.`, `-`, `_`; per-platform format validation happens in each adapter.
+
+**Behavior**:
+- Discord: sends with `message_reference`, showing the native "replying to..." UI
+- Feishu: sends via Reply API (`POST /im/v1/messages/{id}/reply`), showing native quote UI
+- Invalid/non-existent message ID: silently falls back to plain send
+- Works in both streaming and send-once modes
+
+**How agents get message IDs**: Every incoming message includes `message_id` in `SenderContext`:
+
+```json
+{
+ "schema": "openab.sender.v1",
+ "sender_id": "845835116920307722",
+ "sender_name": "pahud.hsieh",
+ "message_id": "1502606076451885136",
+ "channel": "discord",
+ ...
+}
+```
+
+## Multi-Agent Use Case
+
+In a thread with multiple bots, agents can reply to each other's messages:
+
+```
+Human: "Review this PR" (message_id: 100)
+Bot A: "Found 3 issues" (message_id: 101)
+Bot B output:
+ [[reply_to:101]]
+ I agree with Bot A on F1, but F2 is actually fine because...
+```
+
+This creates clear visual conversation threads within a Discord thread — essential for multi-agent collaboration.
+
+## Comparison with Other Platforms
+
+| Platform | Reply Mechanism | Agent Control |
+|----------|----------------|---------------|
+| OpenClaw | `replyToMode` config (`off`/`first`/`all`) | ❌ Platform decides, always to trigger msg |
+| Hermes Agent | `DISCORD_REPLY_TO_MODE` env var | ❌ Platform decides, always to trigger msg |
+| **OAB** | `[[reply_to:message_id]]` directive | ✅ Agent chooses any message |
+
+> **Note:** `reply_to` is currently implemented for Discord and Feishu (gateway). Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR.
diff --git a/docs/pi.md b/docs/pi.md
new file mode 100644
index 000000000..3ff4a19c8
--- /dev/null
+++ b/docs/pi.md
@@ -0,0 +1,109 @@
+# Pi Coding Agent
+
+OpenAB supports the [Pi coding agent](https://github.com/earendil-works/pi-coding-agent) via the `pi-acp` adapter — a Node.js bridge that translates ACP JSON-RPC into Pi CLI invocations.
+
+## Advantages Over Other Native Coding Agents
+
+Pi is a native coding agent that supports subscription-based authentication (like Codex, Cloud Code, and GitHub Copilot). Key advantages:
+
+### No Auth Proxy Required
+
+Pi natively supports Anthropic (Claude Pro/Max) and ChatGPT Plus/Pro subscriptions via OAuth. Unlike agents that require an `openab-auth-proxy` sidecar for subscription forwarding, Pi handles subscription auth directly — reducing deployment complexity and eliminating a moving part.
+
+| Agent | Subscription Auth | Auth Proxy Needed? |
+|-------|------------------|--------------------|
+| Pi | Native OAuth (`pi /login`) | ❌ No |
+| Codex | Native device flow | ❌ No |
+| GitHub Copilot | Native device flow | ❌ No |
+| Claude Code | Native OAuth | ❌ No |
+| Kiro | Native OAuth | ❌ No |
+
+### Minimal Tool Surface (Maximum Context Window)
+
+Pi exposes only 4 core tools: `read`, `write`, `edit`, `bash`. Combined with a tiny system prompt, this drastically reduces prompt overhead and maximizes the available context window for actual project source files.
+
+| Agent | Tool Count | System Prompt Size |
+|-------|-----------|-------------------|
+| Pi | 4 | Minimal |
+| Claude Code | 10+ | Large |
+| Codex | 8+ | Medium |
+| Copilot | 10+ | Large |
+
+### Multi-Model Support
+
+Pi is model-agnostic and supports 15+ LLM providers. Developers can switch models mid-session without restarting the agent or changing configuration.
+
+Supported providers include:
+- Anthropic (Claude) — via subscription or API key
+- OpenAI (GPT/Codex) — via subscription or API key
+- Google (Gemini) — via API key
+- Any OpenAI-compatible endpoint
+
+### Branching Session Trees
+
+Pi saves session history as trees, enabling clean branching of code exploration. This allows developers to explore multiple approaches from a single decision point without losing context.
+
+## Configuration
+
+```toml
+[agent]
+command = "pi-acp"
+working_dir = "/home/node"
+```
+
+## Docker
+
+```bash
+docker build -f Dockerfile.pi -t openab-pi:latest .
+```
+
+## Helm
+
+```yaml
+agents:
+ pi:
+ discord:
+ enabled: true
+ allowedChannels:
+ - "YOUR_CHANNEL_ID"
+ command: pi-acp
+ workingDir: /home/node
+ image: "ghcr.io/openabdev/openab-pi:latest"
+```
+
+## Authentication
+
+```bash
+kubectl exec -it deployment/openab-pi -- pi
+# Once inside the interactive interface, type /login to authenticate
+```
+
+Supported authentication methods:
+
+| Provider | Auth Method | Subscription |
+|----------|-------------|-------------|
+| Anthropic (Claude Pro/Max) | OAuth via `pi /login` | Claude subscription |
+| ChatGPT Plus/Pro | OAuth via `pi /login` | ChatGPT subscription |
+| Any API key provider | `env = { OPENAI_API_KEY = "..." }` | Pay-per-token |
+
+## Steering Files
+
+Pi reads steering files in this order:
+
+1. `.pi/SYSTEM.md` — replaces the default system prompt entirely
+2. `.pi/APPEND_SYSTEM.md` — appends to the default system prompt
+3. `AGENTS.md` — loaded hierarchically (project root → global) for context injection
+
+Place your steering instructions in `/home/node/AGENTS.md` or `/home/node/.pi/APPEND_SYSTEM.md`.
+
+## Persisted Paths (PVC)
+
+| Path | Contents |
+|------|----------|
+| `/home/node/.pi/` | Pi configuration and auth tokens |
+| `/home/node/.pi/sessions/` | Session history trees |
+
+## Limitations
+
+- **No streaming**: `pi-acp` returns the full response at once; streamed output is sent as a single `agent_message_chunk` notification.
+- **Cancel is best-effort**: Pi CLI runs to completion; `session/cancel` may not interrupt mid-generation.
diff --git a/docs/refarch/aws-ecs-fargate-spot.md b/docs/refarch/aws-ecs-fargate-spot.md
new file mode 100644
index 000000000..a4e2b1cf7
--- /dev/null
+++ b/docs/refarch/aws-ecs-fargate-spot.md
@@ -0,0 +1,189 @@
+# Reference Architecture: OpenAB on AWS ECS Fargate Spot
+
+> **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like:
+>
+> ```
+> per https://github.com/openabdev/openab/blob/main/docs/refarch/aws-ecs-fargate-spot.md deploy an openab on ECS Fargate Spot for me in my AWS account
+> ```
+>
+> and it will guide you through (or handle) the full setup on AWS.
+
+Deploy a single OpenAB bot on ECS Fargate Spot for ~$2.7/month with persistent auth via S3.
+
+## Architecture
+
+```
++-- AWS -------------------------------------------------------+
+| |
+| +-- ECS Fargate Spot Task --------------------------------+ |
+| | | |
+| | +-----------+ +----------------+ +------------+ | |
+| | |s3-restore | | openab | | s3-sync | | |
+| | |(init) |->|(main container)| | (sidecar) | | |
+| | |pull auth | | kiro-cli acp | | push auth | | |
+| | |from S3 | | Discord bot | | every 5min | | |
+| | +-----------+ +----------------+ +------------+ | |
+| | | | | | | |
+| | +--------------+- /data volume ----+ | |
+| +---------------------------------------------------------+ |
+| |
+| | | |
+| S3 Bucket Secrets Manager |
+| (auth state) (bot token) |
+| |
++--------------------------------------------------------------+
+ | |
+ Discord API +-- GitHub ------+
+ (bot gateway) | Gist |
+ | (config.toml) |
+ +----------------+
+```
+
+## Cost
+
+| Resource | Spec | Spot Price/mo |
+|----------|------|---------------|
+| Fargate Task | 0.25 vCPU + 512MB | ~$2.7 |
+| S3 | < 1MB state | ~$0 |
+| Secrets Manager | 1 secret | $0.40 |
+| CloudWatch Logs | minimal | ~$0 |
+| **Total** | | **~$3.1/month** |
+
+## Prerequisites
+
+- AWS CLI configured with permissions for ECS, IAM, S3, Secrets Manager, CloudWatch Logs, EC2
+- A Discord bot token (from Discord Developer Portal)
+- Kiro CLI subscription (for OAuth login)
+
+## Deployment Steps
+
+### Phase 1: Store the Discord bot token
+
+Create a Secrets Manager secret with key `DISCORD_BOT_TOKEN`:
+
+```bash
+aws secretsmanager create-secret --name openab \
+ --secret-string '{"DISCORD_BOT_TOKEN":"YOUR_BOT_TOKEN_HERE"}' \
+ --region us-east-1
+```
+
+Note the secret ARN for later.
+
+### Phase 2: Create IAM roles
+
+Create two roles for ECS tasks:
+
+1. **Execution role** (`openab-ecs-execution-role`):
+ - Trust: `ecs-tasks.amazonaws.com`
+ - Attach: `AmazonECSTaskExecutionRolePolicy`
+ - Inline policy: `secretsmanager:GetSecretValue` on the secret ARN
+
+2. **Task role** (`openab-ecs-task-role`):
+ - Trust: `ecs-tasks.amazonaws.com`
+ - Inline policies:
+ - S3: `s3:GetObject`, `s3:PutObject`, `s3:ListBucket`, `s3:DeleteObject` on the state bucket
+ - SSM (for ECS Exec): `ssmmessages:CreateControlChannel`, `CreateDataChannel`, `OpenControlChannel`, `OpenDataChannel`
+
+### Phase 3: Create infrastructure
+
+1. **S3 bucket** for auth state persistence (e.g. `openab-state-`)
+2. **CloudWatch log group** `/ecs/openab`
+3. **ECS cluster** named `openab` with capacity providers `FARGATE_SPOT` + `FARGATE`
+4. **Security group** — egress-only (no inbound rules needed)
+
+### Phase 4: Create the config.toml
+
+Host `config.toml` as a GitHub Gist (recommended) or any HTTPS URL. OpenAB fetches it at startup via `openab run -c `.
+
+Create a **secret gist** (or public if you prefer) with your config:
+
+```bash
+gh gist create --filename config.toml --desc "OpenAB ECS config" - <<'EOF'
+[discord]
+bot_token = "${DISCORD_BOT_TOKEN}"
+allow_all_channels = true
+allow_all_users = true
+allow_bot_messages = "mentions"
+allow_user_messages = "multibot-mentions"
+max_bot_turns = 1000
+message_processing_mode = "per-thread"
+
+[agent]
+command = "kiro-cli"
+args = ["acp", "--trust-all-tools"]
+working_dir = "/home/agent"
+
+[pool]
+max_sessions = 3
+session_ttl_hours = 1
+
+[reactions]
+enabled = true
+remove_after_reply = false
+EOF
+```
+
+Use the raw gist URL (e.g. `https://gist.githubusercontent.com///raw//config.toml`) in Phase 5.
+
+### Phase 5: Register task definition and create service
+
+Register a task definition with three containers:
+
+| Container | Image | Role | Essential |
+|-----------|-------|------|-----------|
+| `s3-restore` | `amazon/aws-cli` | Pull auth from S3 + `chown 1000:1000` | No (init) |
+| `openab` | `ghcr.io/openabdev/openab:latest` | Main bot process | Yes |
+| `s3-sync` | `amazon/aws-cli` | Push auth to S3 every 5 min | No (sidecar) |
+
+Key settings:
+- CPU: 256 (0.25 vCPU), Memory: 512 MB
+- Network mode: `awsvpc`, assign public IP
+- Capacity provider: `FARGATE_SPOT`
+- Enable ECS Exec for interactive login
+- `openab` container depends on `s3-restore` (condition: SUCCESS)
+- `openab` entrypoint: restore auth from shared volume, then `exec openab run -c `
+- Inject `DISCORD_BOT_TOKEN` from Secrets Manager via container `secrets`
+- Shared volume (`agent-data`) mounted at `/data` across all containers
+
+Create an ECS service with `desiredCount: 1`.
+
+### Phase 6: Authenticate Kiro CLI (one-time)
+
+After the task starts, exec in and login:
+
+```bash
+TASK_ID=$(aws ecs list-tasks --cluster openab --service-name openab-kiro \
+ --desired-status RUNNING --query 'taskArns[0]' --output text | awk -F/ '{print $NF}')
+
+aws ecs execute-command --cluster openab --task $TASK_ID \
+ --container openab --interactive \
+ --command "kiro-cli login --use-device-flow"
+```
+
+Then copy auth to the shared volume for S3 persistence:
+
+```bash
+aws ecs execute-command --cluster openab --task $TASK_ID \
+ --container openab --interactive \
+ --command "cp /home/agent/.local/share/kiro-cli/data.sqlite3 /data/data.sqlite3"
+```
+
+The sidecar syncs to S3 within 5 minutes. Future task restarts auto-restore auth.
+
+### Phase 7: Verify
+
+Mention `@YourBot` in a Discord channel. Check logs:
+
+```bash
+aws logs tail /ecs/openab --follow --region us-east-1
+```
+
+Look for: `discord bot connected` → `spawning agent` → streaming response.
+
+## Important Notes
+
+- **Spot interruption**: Task may be reclaimed with 2-min notice. Auth persists via S3; bot reconnects automatically on new task launch.
+- **Auth file ownership**: The S3 restore step must `chown 1000:1000` the auth file — ECS Exec runs as root but kiro-cli runs as uid 1000 (`agent`).
+- **Config via URL**: `openab run -c ` fetches config over HTTPS. Use `${ENV_VAR}` for secrets — expanded at runtime from container environment.
+- **No NAT needed**: Public subnet + `assignPublicIp: ENABLED` gives direct internet access.
+- **Memory**: 512MB is tight (~370MB idle). Bump to 1024MB if sessions OOM.
diff --git a/docs/refarch/ci-observability-discord.md b/docs/refarch/ci-observability-discord.md
new file mode 100644
index 000000000..34159bf75
--- /dev/null
+++ b/docs/refarch/ci-observability-discord.md
@@ -0,0 +1,335 @@
+# Reference Architecture: CI Observability via Discord
+
+> **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like:
+>
+> ```
+> per https://github.com/openabdev/openab/blob/main/docs/refarch/ci-observability-discord.md set up CI notifications to my Discord channel
+> ```
+>
+> and it will guide you through the full setup.
+
+Send GitHub Actions CI results (pass/fail) to a Discord channel or thread via webhook, with full CI metadata and user mentions.
+
+## Problem
+
+When CI runs in GitHub Actions, the only way to know the result is to check the GitHub UI or wait for an email. For teams collaborating in Discord, this creates friction:
+
+- **No visibility** — CI failures go unnoticed until someone manually checks GitHub
+- **Slow feedback loop** — contributors wait without knowing their PR is broken
+- **Context switching** — developers must leave Discord to check CI status
+- **No accountability** — nobody gets pinged when CI breaks
+
+## What We Want
+
+- CI finishes (pass or fail) → automatically POST result to a specific Discord channel/thread
+- Show who committed, how long CI took, and which step failed
+- Include both PR URL and Run URL for quick navigation
+- Mention a specific user so they get pinged
+- Bot-readable content (not hidden in embeds) so mentioned bots can act on it
+- Route notifications to the correct thread based on the PR description
+- One reusable workflow that any CI job can call
+
+## Two Approaches
+
+### Approach 1: Polling Mode (Cronjob)
+
+OpenAB has a built-in cron scheduler. You can schedule the agent to periodically check CI status and fix failures:
+
+```
+@bot can you schedule a cronjob for yourself to this thread and remind yourself to
+"check https://github.com/owner/repo/actions and fix them if required" every 10min?
+```
+
+This creates a `[[cron.jobs]]` entry:
+
+```toml
+[[cron.jobs]]
+schedule = "*/10 * * * *"
+channel = "123456789012345678"
+thread_id = "1505664791719710810"
+message = "check https://github.com/owner/repo/actions and fix them if required"
+```
+
+**Pros:** Holistic view — checks everything on your plate (all workflows, all branches, all repos). Agent can auto-fix issues. No webhook configuration needed.
+
+**Cons:** Up to N-minute delay, unnecessary API calls when nothing changed, burns compute on polling.
+
+### Approach 2: Notification Mode (Webhook Push) ← This Doc
+
+CI pushes results to Discord the moment it finishes — zero delay, zero wasted calls. But it only tells you about **this single CI run**.
+
+```
+GitHub Actions ──finish──► HTTP POST ──► Discord thread
+ (webhook)
+```
+
+**Pros:** Instant notification, no polling cost, precise metadata (duration, failed step, commit info) for the specific run.
+
+**Cons:** Narrow scope — only reports on the workflow that triggered it. Can't see the big picture. Can't auto-fix (notification only). Requires webhook setup.
+
+### Comparison
+
+| | Polling (Cronjob) | Notification (Webhook) |
+|---|---|---|
+| **Scope** | Everything on your plate — all workflows, branches, repos | Single CI run only |
+| **Latency** | Up to N minutes | Instant (on completion) |
+| **Auto-fix** | ✅ Agent can push fixes | ❌ Notification only |
+| **Setup** | Just tell the bot | Webhook + secrets + workflow changes |
+| **Cost** | Burns compute even when idle | Zero cost when nothing runs |
+| **Metadata** | Whatever the agent can scrape | Precise: duration, failed step, commit SHA |
+| **Best for** | "Keep my CI green across all repos" | "Tell me the moment this PR breaks" |
+
+| Scenario | Recommended |
+|----------|-------------|
+| "Tell me when CI breaks" | Notification mode (this doc) |
+| "Check CI and fix it automatically" | Polling mode (cronjob) |
+| Both — notify immediately + auto-fix | Combine: webhook notifies, cronjob retries fixes |
+
+---
+
+## Solution
+
+A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its final job. It posts CI results as plain-text content (bot-readable) with user mention — routing to the correct thread based on the PR description.
+
+## Architecture
+
+```
++-- GitHub Actions ----------------------------------------+
+| |
+| +-- ci.yml ------------------------------------------+ |
+| | | |
+| | [check] ──► cargo fmt / clippy / test | |
+| | │ | |
+| | │ outputs: status, duration, commit_msg, | |
+| | │ commit_author, failed_step | |
+| | ▼ | |
+| | [notify] (if: always()) | |
+| | │ calls ──► notify-discord.yml (reusable) | |
+| | │ | |
+| +-----|----------------------------------------------+ |
+| │ |
+| │ inputs: status, commit_msg, pr_body, ... |
+| │ secrets: DISCORD_WEBHOOK_URL |
+| │ vars: DISCORD_THREAD_ID, DISCORD_MENTION_UID |
+| │ |
++--------|─────────────────────────────────────────────────+
+ │
+ │ HTTP POST (webhook + ?thread_id=xxx)
+ ▼
++-- Discord -----------------------------------------------+
+| |
+| #channel or thread |
+| ┌─────────────────────────────────────────────────┐ |
+| │ ❌ CI failure — repo@main | PR #42 │ |
+| │ 👤 author — commit message │ |
+| │ ⏱️ 3m42s │ |
+| │ 💥 Failed at: Tests │ |
+| │ https://github.com/.../pull/42 │ |
+| │ https://github.com/.../actions/runs/123 │ |
+| │ @user-mention │ |
+| └─────────────────────────────────────────────────┘ |
+| |
++----------------------------------------------------------+
+```
+
+## Key Design Decisions
+
+| Decision | Rationale |
+|----------|-----------|
+| Reusable workflow (`workflow_call`) | Any CI workflow can call it; single source of truth |
+| `if: always()` on notify job | Fires on success, failure, and cancellation |
+| Plain-text content (not embed) | Bots can read `message.content`; embeds are invisible to bots |
+| `printf` + `jq --rawfile` | Only reliable way to get real newlines into JSON payload |
+| Thread ID from PR body | Dynamic routing — each PR notifies its own thread |
+| Fallback to repo variable | Push-to-main events still get notified somewhere |
+| Both PR URL and Run URL | PR for context, Run for debugging logs |
+
+## Setup
+
+### 1. Create a Discord Webhook
+
+Server Settings → Integrations → Webhooks → New Webhook → Copy URL.
+
+### 2. Configure Repository Secrets & Variables
+
+| Type | Name | Value |
+|------|------|-------|
+| **Secret** | `DISCORD_WEBHOOK_URL` | The webhook URL (contains token — keep secret) |
+| **Variable** | `DISCORD_THREAD_ID` | Default thread ID for fallback notifications |
+| **Variable** | `DISCORD_MENTION_USER_ID` | Discord user ID to mention (e.g. `1234567890`) |
+
+Set via CLI:
+
+```bash
+gh secret set DISCORD_WEBHOOK_URL --repo /
+gh variable set DISCORD_THREAD_ID --repo / --body ""
+gh variable set DISCORD_MENTION_USER_ID --repo / --body ""
+```
+
+### 3. Create the Reusable Workflow
+
+`.github/workflows/notify-discord.yml`:
+
+```yaml
+name: Discord Notify
+
+on:
+ workflow_call:
+ inputs:
+ status:
+ required: true
+ type: string
+ failed_step:
+ required: false
+ type: string
+ duration:
+ required: false
+ type: string
+ commit_msg:
+ required: false
+ type: string
+ commit_author:
+ required: false
+ type: string
+ pr_body:
+ required: false
+ type: string
+ secrets:
+ DISCORD_WEBHOOK_URL:
+ required: true
+
+jobs:
+ notify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Send Discord notification
+ env:
+ WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ DEFAULT_THREAD_ID: ${{ vars.DISCORD_THREAD_ID }}
+ MENTION_USER_ID: ${{ vars.DISCORD_MENTION_USER_ID }}
+ STATUS: ${{ inputs.status }}
+ FAILED_STEP: ${{ inputs.failed_step }}
+ DURATION: ${{ inputs.duration }}
+ COMMIT_MSG: ${{ inputs.commit_msg }}
+ COMMIT_AUTHOR: ${{ inputs.commit_author }}
+ PR_BODY: ${{ inputs.pr_body }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ REPO: ${{ github.repository }}
+ REF: ${{ github.ref_name }}
+ PR: ${{ github.event.pull_request.number }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ # Extract Thread ID from PR body, fallback to variable
+ THREAD_ID=""
+ if [ -n "$PR_BODY" ]; then
+ THREAD_ID=$(echo "$PR_BODY" | grep -ioP '^Thread:\s*\K[0-9]+' | head -1)
+ fi
+ [ -z "$THREAD_ID" ] && THREAD_ID="$DEFAULT_THREAD_ID"
+
+ if [ "$STATUS" = "success" ]; then
+ EMOJI="✅"
+ else
+ EMOJI="❌"
+ fi
+
+ # Build message into a temp file for proper newlines
+ {
+ printf '%s **CI %s** — `%s@%s`' "$EMOJI" "$STATUS" "$REPO" "$REF"
+ [ -n "$PR" ] && printf ' | PR #%s' "$PR"
+ echo ""
+ [ -n "$COMMIT_AUTHOR" ] && printf '👤 %s' "$COMMIT_AUTHOR"
+ [ -n "$COMMIT_MSG" ] && printf ' — `%s`' "$COMMIT_MSG"
+ [ -n "$COMMIT_AUTHOR" ] && echo ""
+ [ -n "$DURATION" ] && echo "⏱️ ${DURATION}"
+ [ "$STATUS" != "success" ] && [ -n "$FAILED_STEP" ] && echo "💥 Failed at: **${FAILED_STEP}**"
+ [ -n "$PR" ] && echo "${SERVER_URL}/${REPO}/pull/${PR}"
+ echo "$RUN_URL"
+ [ -n "$MENTION_USER_ID" ] && echo "<@${MENTION_USER_ID}>"
+ } > /tmp/msg.txt
+
+ # Use jq --rawfile to preserve real newlines in JSON
+ PAYLOAD=$(jq -n --rawfile msg /tmp/msg.txt \
+ '{content: $msg, allowed_mentions: {parse: ["users"]}}')
+
+ URL="${WEBHOOK_URL}"
+ [ -n "$THREAD_ID" ] && URL="${URL}?thread_id=${THREAD_ID}"
+
+ curl -sf -X POST "$URL" \
+ -H "Content-Type: application/json" \
+ -d "$PAYLOAD"
+```
+
+### 4. Wire Into Your CI Workflow
+
+Add a `notify` job at the end of any workflow:
+
+```yaml
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ outputs:
+ duration: ${{ steps.duration.outputs.value }}
+ commit_msg: ${{ steps.meta.outputs.commit_msg }}
+ commit_author: ${{ steps.meta.outputs.commit_author }}
+ failed_step: ${{ steps.meta.outputs.failed_step }}
+ steps:
+ - name: Record start time
+ id: start
+ run: echo "ts=$(date +%s)" >> "$GITHUB_OUTPUT"
+
+ # ... your build/test steps (give each an id) ...
+
+ - name: Collect metadata
+ id: meta
+ if: always()
+ run: |
+ echo "commit_msg=$(git log -1 --pretty=%s)" >> "$GITHUB_OUTPUT"
+ echo "commit_author=$(git log -1 --pretty=%an)" >> "$GITHUB_OUTPUT"
+ # Detect which step failed
+ FAILED=""
+ # if [ "${{ steps.test.outcome }}" = "failure" ]; then FAILED="Tests"; fi
+ echo "failed_step=${FAILED}" >> "$GITHUB_OUTPUT"
+
+ - name: Calculate duration
+ id: duration
+ if: always()
+ run: |
+ ELAPSED=$(( $(date +%s) - ${{ steps.start.outputs.ts }} ))
+ echo "value=$((ELAPSED/60))m$((ELAPSED%60))s" >> "$GITHUB_OUTPUT"
+
+ notify:
+ needs: [check]
+ if: always()
+ uses: ./.github/workflows/notify-discord.yml
+ with:
+ status: ${{ needs.check.result }}
+ failed_step: ${{ needs.check.outputs.failed_step }}
+ duration: ${{ needs.check.outputs.duration }}
+ commit_msg: ${{ needs.check.outputs.commit_msg }}
+ commit_author: ${{ needs.check.outputs.commit_author }}
+ pr_body: ${{ github.event.pull_request.body }}
+ secrets:
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+```
+
+### 5. Dynamic Thread Routing via PR Description
+
+Add a `Thread:` line anywhere in your PR description:
+
+```
+Thread: 1505664791719710810
+```
+
+The workflow extracts the first match and posts to that thread. If absent, it falls back to `DISCORD_THREAD_ID` variable.
+
+## Gotchas
+
+| Issue | Solution |
+|-------|----------|
+| Embed content invisible to bots | Use plain-text `content` field — bots only see `message.content` |
+| `\n` in `jq --arg` becomes literal `\\n` | Write to temp file, use `jq --rawfile` to preserve real newlines |
+| Duplicate YAML keys silently break workflows | Validate with `actionlint` or check Actions run errors |
+| Webhook URL contains a token | Always store as a **secret**, never in workflow files or docs |
+| `if: always()` required on notify job | Otherwise it's skipped when upstream jobs fail |
+| Mention requires numeric Discord user ID | Use `<@USER_ID>` format in `content` |
+| Webhook mentions don't trigger bots | Webhook messages don't fire bot `MESSAGE_CREATE` — mention real users instead |
diff --git a/docs/refarch/sidecar-proxy.md b/docs/refarch/sidecar-proxy.md
new file mode 100644
index 000000000..ca85e2771
--- /dev/null
+++ b/docs/refarch/sidecar-proxy.md
@@ -0,0 +1,84 @@
+# Reference Architecture: OAuth Sidecar Proxy
+
+> **Note:** For xAI/Grok models, OpenCode ≥1.15.0 supports native xAI OAuth.
+> The sidecar proxy is no longer required for OpenCode deployments.
+> See [docs/xai-proxy.md](../xai-proxy.md) for the recommended approach.
+
+This document describes the **sidecar proxy pattern** implemented by
+`openab-auth-proxy` — a generic OAuth proxy that injects Bearer tokens into
+upstream API requests.
+
+## When to use this pattern
+
+- Agents **without** built-in OAuth (Hermes, custom agents)
+- Centralizing token management across multiple containers in a pod
+- Proxying to any OAuth-protected API (not just xAI)
+
+## Architecture
+
+```
+┌─ Kubernetes Pod ──────────────────────────────────────────────┐
+│ │
+│ agent container (any OpenAI-compatible client) │
+│ │ POST /v1/chat/completions │
+│ │ (no auth header needed) │
+│ ▼ │
+│ openab-auth-proxy :9090 │
+│ • Reads OAuth token from disk │
+│ • Injects Authorization: Bearer header │
+│ • Auto-refreshes 120s before expiry │
+│ │ │
+│ Token: ~/.openab-auth-proxy//tokens.json │
+└───────────────┼───────────────────────────────────────────────┘
+ ▼
+ upstream API (configured via TOML or xAI default)
+```
+
+## Configuration
+
+Without a config file, `openab-auth-proxy` defaults to xAI/SuperGrok.
+
+For other providers, create `auth-proxy.toml`:
+
+```toml
+[provider]
+name = "my-provider"
+discovery_url = "https://auth.example.com/.well-known/openid-configuration"
+client_id = "my-client-id"
+scopes = "openid offline_access api:access"
+upstream_base_url = "https://api.example.com"
+redirect_port = 8080
+```
+
+## Helm deployment (xAI example)
+
+```bash
+# 1. Login locally
+openab-auth-proxy login-device
+
+# 2. Create K8s secret
+kubectl create secret generic auth-proxy-tokens \
+ --from-file=tokens.json=$HOME/.openab-auth-proxy/xai/tokens.json
+
+# 3. Deploy with sidecar
+helm install openab openab/openab \
+ --set agents.mybot.command=opencode \
+ --set-json 'agents.mybot.args=["acp"]' \
+ --set agents.mybot.image=ghcr.io/openabdev/openab-opencode \
+ --set-json 'agents.mybot.extraContainers=[{"name":"auth-proxy","image":"ghcr.io/openabdev/openab-auth-proxy:latest","args":["serve","--bind","0.0.0.0"],"ports":[{"containerPort":9090}],"volumeMounts":[{"name":"data","mountPath":"/home/agent"}]}]' \
+ --set-json 'agents.mybot.extraInitContainers=[{"name":"copy-tokens","image":"busybox","command":["sh","-c","mkdir -p /dest/.openab-auth-proxy/xai && cp /src/tokens.json /dest/.openab-auth-proxy/xai/tokens.json"],"volumeMounts":[{"name":"tokens-src","mountPath":"/src","readOnly":true},{"name":"data","mountPath":"/dest"}]}]' \
+ --set-json 'agents.mybot.extraVolumes=[{"name":"tokens-src","secret":{"secretName":"auth-proxy-tokens"}}]'
+```
+
+## Environment variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `AUTH_PROXY_TOKEN_PATH` | `~/.openab-auth-proxy//tokens.json` | Token file location |
+| `XAI_PROXY_TOKEN_PATH` | (legacy alias) | Backward-compatible |
+| `RUST_LOG` | `openab_auth_proxy=info` | Log verbosity |
+
+## See also
+
+- [openab-auth-proxy source](../../openab-auth-proxy/) — Rust implementation
+- [docs/xai-proxy.md](../xai-proxy.md) — xAI-specific quick-start
diff --git a/docs/refarch/telegram-cloudflare-tunnel.md b/docs/refarch/telegram-cloudflare-tunnel.md
new file mode 100644
index 000000000..67df7a84f
--- /dev/null
+++ b/docs/refarch/telegram-cloudflare-tunnel.md
@@ -0,0 +1,163 @@
+# Reference Architecture: Telegram via Cloudflare Tunnel
+
+Deploy OpenAB on K3s with the Custom Gateway receiving Telegram webhooks through a Cloudflare Tunnel — no public IP, no ingress controller, no TLS certificates required.
+
+## Architecture
+
+```
+Telegram Cloud
+ │ HTTPS POST
+ ▼
+Cloudflare Edge (your_custom.domain.com)
+ │ Tunnel (QUIC)
+ ▼
+┌─────────────────────────────────────────────────────┐
+│ Single Pod │
+│ │
+│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ cloudflared │─▶│gateway :8080 │◀─│ OAB │ │
+│ │ (sidecar) │ │ (sidecar) │ws│ (main) │ │
+│ └─────────────┘ └──────────────┘ └──────────────┘ │
+│ localhost localhost │
+└─────────────────────────────────────────────────────┘
+```
+
+All three components run as containers in the **same pod**:
+
+- **cloudflared** — tunnel client, forwards Cloudflare traffic to `localhost:8080`
+- **gateway** — receives Telegram webhooks, normalizes events, serves WebSocket on `:8080`
+- **OAB** — connects to `ws://localhost:8080/ws`, runs the agent
+
+This keeps all communication on `localhost` — no K8s Services or cross-pod networking required.
+
+## Prerequisites
+
+| Requirement | Notes |
+|-------------|-------|
+| K3s cluster | Any single-node or multi-node K3s setup |
+| Helm 3 | Installed on the node or a workstation with kubeconfig access |
+| Cloudflare account | Free plan is sufficient |
+| Telegram Bot Token | Create via [@BotFather](https://t.me/BotFather) |
+| Domain on Cloudflare | DNS managed by Cloudflare |
+
+## Step 1: Create a Cloudflare Tunnel
+
+1. Go to **Zero Trust → Networks → Tunnels → Create a tunnel**
+2. Name it (e.g. `openab-telegram`)
+3. Copy the **tunnel token**
+4. Add a **public hostname**:
+ - Subdomain: your choice (e.g. `bot`)
+ - Domain: your Cloudflare-managed domain
+ - Service: `http://localhost:8080`
+
+## Step 2: Deploy with Helm
+
+```bash
+cd openab
+
+RELEASE_NAME="my-openab"
+
+helm upgrade --install "$RELEASE_NAME" ./charts/openab \
+ --set agents.kiro.discord.enabled=false \
+ --set agents.kiro.gateway.enabled=true \
+ --set agents.kiro.gateway.deploy=false \
+ --set agents.kiro.gateway.url="ws://localhost:8080/ws" \
+ --set agents.kiro.gateway.platform=telegram \
+ --set agents.kiro.extraContainers[0].name=gateway \
+ --set agents.kiro.extraContainers[0].image="ghcr.io/openabdev/openab-gateway:0.4.0" \
+ --set agents.kiro.extraContainers[0].env[0].name=TELEGRAM_BOT_TOKEN \
+ --set-literal agents.kiro.extraContainers[0].env[0].value="" \
+ --set agents.kiro.extraContainers[1].name=cloudflared \
+ --set agents.kiro.extraContainers[1].image="cloudflare/cloudflared:latest" \
+ --set agents.kiro.extraContainers[1].args[0]="tunnel" \
+ --set agents.kiro.extraContainers[1].args[1]="--no-autoupdate" \
+ --set agents.kiro.extraContainers[1].args[2]="run" \
+ --set agents.kiro.extraContainers[1].args[3]="--token" \
+ --set-literal agents.kiro.extraContainers[1].args[4]="" \
+ --namespace openab --create-namespace
+```
+
+> **Key difference:** `gateway.deploy=false` skips the separate gateway Deployment/Service. Instead, gateway and cloudflared run as `extraContainers` sidecars in the OAB pod, communicating over `localhost`.
+
+## Step 3: Authenticate the Agent
+
+```bash
+kubectl exec -it deployment/${RELEASE_NAME}-kiro -n openab -- kiro-cli login --use-device-flow
+```
+
+After login, restart the pod to pick up credentials:
+
+```bash
+kubectl rollout restart deployment/${RELEASE_NAME}-kiro -n openab
+```
+
+## Step 4: Set the Telegram Webhook
+
+```bash
+curl "https://api.telegram.org/bot/setWebhook" \
+ -d "url=https://your_custom.domain.com/webhook/telegram"
+```
+
+Verify:
+
+```bash
+curl "https://api.telegram.org/bot/getWebhookInfo"
+```
+
+## Resulting Resources
+
+```
+$ kubectl get pods -n openab
+NAME READY STATUS AGE
+my-openab-kiro-xxxxx-yyyyy 3/3 Running ...
+```
+
+The single pod runs 3 containers: `kiro` (OAB agent), `gateway`, and `cloudflared`.
+
+## Configuration
+
+The rendered `config.toml` for the OAB agent:
+
+```toml
+[agent]
+command = "kiro-cli"
+args = ["acp", "--trust-all-tools"]
+working_dir = "/home/agent"
+
+[pool]
+max_sessions = 10
+session_ttl_hours = 24
+
+[reactions]
+enabled = true
+remove_after_reply = false
+
+[gateway]
+url = "ws://localhost:8080/ws"
+platform = "telegram"
+url = "ws://localhost:8080/ws"
+platform = "telegram"
+allow_all_channels = true
+allowed_channels = []
+# ⚠️ Recommended: restrict to specific Telegram user IDs
+allow_all_users = false
+allowed_users = [""]
+```
+
+## Restricting Access
+
+To limit which Telegram users can interact with the bot:
+
+```bash
+helm upgrade $RELEASE_NAME ./charts/openab \
+ ... \
+ --set agents.kiro.gateway.allowAllUsers=false \
+ --set-string agents.kiro.gateway.allowedUsers[0]=""
+```
+
+## Why Cloudflare Tunnel?
+
+- **No public IP required** — the K3s node can be behind NAT or a firewall.
+- **No TLS management** — Cloudflare terminates TLS at the edge.
+- **No ingress controller config** — bypasses Traefik/nginx entirely.
+- **Single-pod simplicity** — all components share `localhost`, no cross-pod networking needed.
diff --git a/docs/slash-commands.md b/docs/slash-commands.md
index 6d24a63a5..080c357a0 100644
--- a/docs/slash-commands.md
+++ b/docs/slash-commands.md
@@ -10,6 +10,8 @@ OpenAB registers Discord slash commands for session control. These work in both
| `/agents` | Select the agent mode via dropdown menu | Yes |
| `/cancel` | Cancel the current in-flight operation | Yes |
| `/reset` | Reset the conversation session (clear history, start fresh) | Yes |
+| `/remind` | Set a one-shot delayed reminder to mention users/roles | No |
+| `/export-thread` | Export thread/DM as `.txt` (default: last 100 messages) | No |
All responses are **ephemeral** — only the user who invoked the command sees the reply.
@@ -63,6 +65,35 @@ This is equivalent to the `sessions close` + `sessions new` pattern used by [Ope
- Bot identity and system prompt (re-applied on next session creation)
- Config settings in `config.toml`
+### `/export-thread`
+
+Fetches the current Discord thread or DM history and returns a `.txt` file as an ephemeral follow-up. The transcript includes message timestamps, author names and IDs, message text, and attachment URLs.
+
+**Optional parameters** (mutually exclusive — use at most one):
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `limit` | Integer | Export only the most recent N messages (1–5000) |
+| `since` | String | Export messages after this message ID (right-click → Copy Message ID) |
+| `days` | Integer | Export messages from the last N days (1–365). Rolling N×24h window from now. |
+| `all` | Boolean | Export all messages (up to 5000) |
+
+If no parameter is provided, the **last 100 messages** are exported.
+
+**Examples:**
+```
+/export-thread → last 100 messages (default)
+/export-thread limit:500 → most recent 500 messages
+/export-thread since:1503744866100842698 → messages after this specific message
+/export-thread days:3 → messages from the last 3 days (rolling 72h)
+/export-thread all:true → export all (cap 5000)
+```
+
+**Constraints:**
+- Only works in allowed Discord threads or enabled DMs.
+- Specifying more than one filter returns an error.
+- Very large exports may be truncated to fit Discord's attachment size limit.
+
## Passing CLI Commands via @mention
In addition to slash commands, you can pass built-in CLI commands directly after an @mention:
@@ -74,3 +105,45 @@ In addition to slash commands, you can pass built-in CLI commands directly after
```
These are forwarded as-is to the ACP session as a prompt. Any command the underlying CLI supports in its interactive mode works here. This is the recommended workaround for agents that don't expose `configOptions`.
+
+## `/remind`
+
+Set a one-shot delayed reminder that mentions users or roles in the channel after a specified delay.
+
+**Syntax:**
+```
+/remind targets:<@user @role ...> message: delay:
+```
+
+**Parameters:**
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `targets` | Yes | Space-separated @mentions (users and/or roles) |
+| `message` | Yes | Reminder text |
+| `delay` | Yes | Duration before firing: `1m` to `30d` (supports `m`, `h`, `d` and combinations like `1h30m`) |
+
+**Constraints:**
+- Only humans can use `/remind` (bots are rejected)
+- Minimum delay: 1 minute
+- Maximum delay: 30 days
+- Maximum message length: 1800 characters
+- Maximum 5 active reminders per user
+- Maximum 10 mention targets per reminder (use a @role for larger groups)
+- `@everyone` and `@here` in messages are automatically neutralized (will not trigger mass mentions)
+- One-shot only (fires once, then removed)
+- Reminders persist across bot restarts (stored in `$HOME/.openab/reminders.json`)
+
+**Examples:**
+```
+/remind targets:@Alice @Bob message:Review PR #42 delay:2h
+/remind targets:@Reviewers message:Stand-up time delay:30m
+/remind targets:@Charlie message:Check deployment delay:1d
+```
+
+**When fired, the bot posts:**
+```
+⏰ Reminder from @sender:
+"Review PR #42"
+cc @Alice @Bob
+```
diff --git a/docs/steering-design-guide.md b/docs/steering-design-guide.md
new file mode 100644
index 000000000..aed5b13ed
--- /dev/null
+++ b/docs/steering-design-guide.md
@@ -0,0 +1,252 @@
+# Steering Design Guide
+
+## Problem
+
+AI coding agents load persistent instructions every session, but without deliberate organization:
+- **Bloated instructions** dilute attention — critical rules get buried in noise
+- **Duplicated rules** across files inevitably contradict each other when one is updated
+- **Missing triggers** mean mandatory behaviors live in docs the agent never reads
+- **No shared standard** across agents leads to each team reinventing the wheel
+
+This guide establishes a universal framework for organizing agent memory into layers, so rules are reliably followed, context budgets are respected, and teams can onboard new agents without starting from scratch.
+
+OpenAB is designed to be agent-agnostic — it supports Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode, and Pi running side by side. This guide provides a shared memory architecture standard that allows all supported coding agents to maintain consistent behavior, collaborate effectively, and operate from a single source of truth regardless of their underlying platform differences.
+
+---
+
+How to organize AI agent memory across three tiers: hot (always loaded), warm (triggered on demand), and cold (searched when needed).
+
+Applies to: Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode, Pi — any agent that supports persistent instruction files.
+
+---
+
+## Terminology
+
+| Term | Meaning | Examples |
+|------|---------|---------|
+| 🔥 **Hot memory** | Loaded every session, always in context | `AGENTS.md`, `.kiro/steering/`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md` |
+| ☕ **Warm context** | Not always loaded, but auto-triggered when conditions match | Codex Skills (body), Copilot path-specific instructions, CC/Gemini individual memory files (pointed to by hot index), subdir instruction files |
+| ❄️ **Cold storage** | Searched or retrieved on demand, no automatic trigger | Knowledge bases, `docs/`, project wikis, ADRs, historical records |
+
+---
+
+## What Goes in Hot Memory
+
+| Criteria | Example |
+|----------|---------|
+| Every interaction might trigger it | Output format spec, verdict logic |
+| Identity & relationships | Agent name, team members, contact IDs |
+| SOP trigger words | "review PRs" → auto-execute workflow |
+| Hard rules that are easy to get wrong | "NITs are blocking", "never merge", "English only on GitHub" |
+| Tool usage patterns | Login flows, API call patterns |
+| Constraints that override defaults | "Don't ask for confirmation on X", "Always do Y before Z" |
+
+## What Stays in Cold Storage
+
+| Criteria | Example |
+|----------|---------|
+| Historical records / case studies | Past incident lessons, collaboration logs |
+| One-time reference | Installation steps, migration guides |
+| Large data | User profiles, conversation history |
+| Design proposals / RFCs | Architecture decisions, feature specs |
+| Lookup tables | Feature flags, config reference, changelogs |
+
+## What Goes in Warm Context
+
+| Criteria | Example |
+|----------|---------|
+| Too large for hot, but has a reliable trigger | Deployment SOP, release checklist |
+| Only relevant for specific file types or paths | Gateway adapter checklist, Helm wiring rules |
+| Domain-specific expert knowledge | Platform auth spec details, crypto implementation patterns |
+| Complex workflows with steps and scripts | Incident triage playbook, skill bodies |
+
+**Rule of thumb:** If it has a clear trigger condition and is > 1KB, make it warm. Keep only the trigger (name + one-line description + path) in hot.
+
+---
+
+## Design Principles
+
+1. **Small and precise** — Keep hot memory concise. Practical caps vary by agent (CC: ~200 lines for MEMORY.md, Codex: 32KB, Kiro: ~15KB recommended). Regardless of hard limits, attention dilution is the real constraint — less is more.
+2. **Behavior-oriented** — Every line should change "what the agent does next." Remove anything that's just "nice to know."
+3. **Single source of truth** — Define each rule in exactly one place. Duplication across files creates contradiction risk.
+4. **Testable** — Each rule should be verifiable with a single prompt from a fresh session.
+5. **One file per responsibility** — Separate concerns: identity, review process, workflow triggers. Avoid monolithic instruction files.
+6. **Hot/cold separation** — If the agent can find it via search when needed, it doesn't need to be always-loaded.
+7. **Structure over prose** — Use lists, tables, or key-value pairs in hot memory. LLMs follow structured constraints more reliably than natural language paragraphs.
+8. **WHAT and HOW only** — Hot memory defines what to do and how. Put the WHY (historical context, incident backstory) in cold storage (ADRs, lessons learned).
+
+---
+
+## Decision Flowchart
+
+```
+"If this rule is NOT loaded, will the next response be wrong?"
+│
+├─ Yes → 🔥 Hot memory
+│
+├─ Only when doing a specific task/touching specific files
+│ → ☕ Warm context (put trigger in hot, body in warm)
+│
+└─ No, it's reference → ❄️ Cold storage
+```
+
+---
+
+## Architecture Pattern
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 🔥 HOT — Always Loaded │
+│ │
+│ Identity, hard rules, collaboration protocol, trigger index │
+│ AGENTS.md / CLAUDE.md / GEMINI.md / .kiro/steering/* │
+│ .github/copilot-instructions.md / MEMORY.md index │
+│ < 15KB │
+├─────────────────────────────────────────────────────────────────┤
+│ ☕ WARM — Progressive Exposure │
+│ │
+│ Auto-loaded when trigger condition matches │
+│ Skills (SKILL.md body), path-specific instructions, │
+│ subdir instruction files, individual memory files, │
+│ domain SOPs, deployment playbooks │
+│ │
+│ Triggers: Rule-based (applyTo glob) │
+│ Semantic (agent reads index, decides to load) │
+│ Explicit (activate_skill / read_file) │
+├─────────────────────────────────────────────────────────────────┤
+│ ❄️ COLD — Search on Demand │
+│ │
+│ No automatic trigger; requires explicit search/retrieval │
+│ Knowledge bases, docs/*.md, wikis, ADRs, RFCs, │
+│ historical records, lessons learned, team trivia │
+│ Unlimited │
+└─────────────────────────────────────────────────────────────────┘
+
+ ▲ Smaller, precise, behavioral
+ │
+ ▼ Larger, reference, historical
+```
+
+**Key insight:** The warm layer's *trigger metadata* lives in hot memory (skill names, index entries, applyTo globs). Only the *body* loads on demand. Pattern: **put the table of contents in hot, put the chapters in warm.**
+
+> **Trigger mechanisms vary by agent:**
+> - **Rule-based:** Copilot `applyTo` glob match, Codex skill metadata match
+> - **Semantic:** CC/Gemini memory index — agent reads description and decides to load
+> - **Explicit:** Agent calls `activate_skill` or `read_file` when task matches
+
+> **Real-world example:** Claude Code's auto-memory system is a natural implementation of hot/warm separation — `MEMORY.md` index (hot, 200-line cap) points to individual `.md` memory files (warm, loaded when agent determines relevance from index description).
+
+> **Common pattern:** CC, Codex, and Gemini all use hierarchical loading (global → project → subdir). This naturally supports "one file per responsibility" by placing topic-specific rules in the relevant subdirectory's instruction file.
+
+---
+
+## Agent-Specific File Mapping
+
+> **Note:** Most agents are hybrid — they combine multiple loading models. The table below shows the primary mechanisms.
+
+### Loading Models
+
+| Model | Trigger | Examples |
+|-------|---------|---------|
+| **Always loaded** | Every session/interaction in repo context | Kiro `.kiro/steering/*`, CC/Codex/Gemini root instruction file, Copilot `.github/copilot-instructions.md` |
+| **Directory-scoped** | Processing files within that directory tree | CC/Codex/Gemini subdir instruction files, Copilot `AGENTS.md` (nearest-in-tree) |
+| **File-scoped** | Matching an `applyTo` glob pattern | Copilot `.github/instructions/**/*.instructions.md` |
+
+**Implication for hot memory design:**
+- "Always loaded" = put task-agnostic rules here (identity, verdict logic, workflow triggers)
+- "Directory-scoped" = put domain-specific rules here (gateway checklist, docs standards)
+- "File-scoped" = put file-type-specific review expectations here (only Copilot supports this natively)
+
+| Agent | Hot Memory Location | Notes |
+|-------|-------------------|-------|
+| Kiro | `AGENTS.md` + `.kiro/steering/*.md` | Multiple files, one per topic |
+| Claude Code | `CLAUDE.md` (project) + `~/.claude/CLAUDE.md` (global) + `MEMORY.md` index | Hierarchical loading (global → project → subdir). Auto-memory index is hot (200-line cap); individual memory files are cold. `settings.json` is config, not instructions |
+| Codex | `AGENTS.md` hierarchical (global → project root → subdir) | Each directory loads at most one file. 32KB cap (`project_doc_max_bytes`). Use nested `AGENTS.md` for per-directory responsibility split. No multi-file topic split within same dir |
+| Gemini | `GEMINI.md` hierarchical (`~/.gemini/GEMINI.md` global → `./GEMINI.md` project → subdir) + `MEMORY.md` index | Same hierarchical pattern as CC/Codex. Private project memory index is hot; individual memory files are cold |
+| Copilot | `.github/copilot-instructions.md` (repo-wide) + `.github/instructions/**/*.instructions.md` (path-specific) + `AGENTS.md` (nearest-in-tree, where supported: cloud agent / CLI) | Layered: Personal > Path-specific > Repo-wide > Agent > Organization. No documented hard size cap for Chat/Agent (code review reads first 4K chars only). Keep short (~2 pages recommended) |
+| OpenCode | `AGENTS.md` or equivalent | Follows repo convention |
+| Pi | `AGENTS.md` hierarchical (project root → global) + `SYSTEM.md` or `APPEND_SYSTEM.md` in `.pi/` | Project or global `SYSTEM.md` replaces the default system prompt, while `APPEND_SYSTEM.md` appends to it. `AGENTS.md` is loaded hierarchically for context injection |
+
+---
+
+## Validation Checklist
+
+After adding or changing hot memory:
+
+1. **Start a fresh session** (no prior context)
+2. **Ask a question that triggers the rule** — e.g., "what format should a review comment use?"
+3. **Verify the response follows the rule exactly**
+4. **Test edge cases** — e.g., "what if there's only one 🟡 finding?"
+5. **Check for contradictions** — does the new rule conflict with anything else in hot memory?
+
+If the agent doesn't follow the rule → it's either not loaded, too buried in other content, or ambiguously worded.
+
+---
+
+## Anti-Patterns
+
+| Anti-Pattern | Why It's Bad | Fix |
+|--------------|-------------|-----|
+| Dumping everything into one file | Critical rules get lost in noise | Split by responsibility |
+| Duplicating rules across files | Inevitable contradictions when one is updated | Single source + pointer |
+| Putting case studies in hot memory | Wastes context budget on history | Move to docs, reference by lesson only |
+| Vague rules ("be helpful") | Untestable, no behavioral change | Make specific and testable |
+| Hot memory > 20KB | Diminishing returns, attention dilution | Audit and move cold items out |
+| Task-scoped rules in file/directory-scoped locations | Review SOP, response format, collaboration protocol only load when certain files are touched — missing when needed most | Put task-agnostic workflow rules in always-loaded layer, not path-specific |
+| Stale links in hot memory | Index points to missing files; fresh session gets dead references | Audit links quarterly; remove or create the target |
+| Mandatory behavior hidden in cold without trigger | Agent must follow it but has no path to discover it | Add trigger metadata to hot, or promote to warm with clear activation condition |
+
+---
+
+## Maintenance
+
+- **Quarterly audit**: Review hot memory files. Remove rules that are no longer relevant or have become default behavior.
+- **After contradictions**: When agent behavior contradicts a rule, check if it's a loading issue or a conflict with another rule.
+- **After new capabilities**: When adding new workflows, decide hot vs cold before writing the doc.
+- **Adding a new agent**: Document its loading model and precedence before adding file mappings. Don't assume it works like existing agents.
+
+---
+
+## Self-Reflection Prompt
+
+Use this prompt from a fresh session to audit memory allocation against this guide:
+
+```
+Per the steering design guide in docs/steering-design-guide.md from OpenAB GitHub repo, review your current memory allocation:
+
+1. INVENTORY — List all loaded/discoverable instruction sources:
+ - File path
+ - Layer (Hot / Warm / Cold)
+ - Trigger model (always / directory / file-glob / semantic / explicit)
+ - Approximate size (lines or KB)
+
+2. CLASSIFY — For each item, what type of content is it?
+ - WHAT/HOW (behavior rule) vs WHY (history/rationale)
+ - Identity / hard rule / workflow / reference / trivia
+
+3. VIOLATIONS — Identify items that break the guide's principles:
+ - Not behavior-oriented (nice-to-know in hot)
+ - Duplicated or conflicting across files
+ - Stale links pointing to missing files
+ - Too large for its layer
+ - WHY/history in hot instead of cold
+ - Mandatory behavior in cold with no trigger path
+
+4. TRIGGER QUALITY — Review warm layer triggers:
+ - Are index descriptions precise enough to fire correctly?
+ - Where is the canonical source for each rule?
+ - Will the agent reliably see it when needed?
+
+5. OPTIMIZATION PLAN — Propose concrete moves:
+ - Remove (stale, duplicate, irrelevant)
+ - Keep in hot (behavioral, high-frequency)
+ - Promote cold → warm (add trigger)
+ - Demote hot → warm or cold (too large, low-frequency)
+ - Split (one file doing too many jobs)
+ - Add missing trigger/index entry
+
+6. VERIFY — Name one fresh-session test prompt that would confirm
+ the highest-risk rule still loads correctly.
+```
+
+Expected output: a before/after table with file paths, layer assignments, sizes, and rationale for each move.
diff --git a/docs/stt.md b/docs/stt.md
index 5ee8fa488..5e76ff545 100644
--- a/docs/stt.md
+++ b/docs/stt.md
@@ -1,6 +1,6 @@
# Speech-to-Text (STT) for Voice Messages
-openab can automatically transcribe Discord voice message attachments and forward the transcript to your ACP agent as text.
+openab can automatically transcribe voice message attachments (Discord, Feishu, and other gateway platforms) and forward the transcript to your ACP agent as text.
## Quick Start
@@ -24,7 +24,7 @@ api_key = "${GROQ_API_KEY}"
## How It Works
```
-Discord voice message (.ogg)
+Voice message (Discord .ogg, Feishu opus/ogg, etc.)
│
▼
openab downloads the audio file
@@ -50,6 +50,7 @@ enabled = true # default: false
api_key = "${GROQ_API_KEY}" # required for cloud providers
model = "whisper-large-v3-turbo" # default
base_url = "https://api.groq.com/openai/v1" # default
+echo_transcript = true # default: false (opt-in)
```
| Field | Required | Default | Description |
@@ -58,6 +59,7 @@ base_url = "https://api.groq.com/openai/v1" # default
| `api_key` | no* | — | API key for the STT provider. *Auto-detected from `GROQ_API_KEY` env var if not set. For local servers, use any non-empty string (e.g. `"not-needed"`). |
| `model` | no | `whisper-large-v3-turbo` | Whisper model name. Varies by provider. |
| `base_url` | no | `https://api.groq.com/openai/v1` | OpenAI-compatible API base URL. |
+| `echo_transcript` | no | `false` | When set to `true` and STT runs, post a `> 🎤 ` message to the thread before the agent reply so users can verify what was heard. Failures show `(transcription failed)` and add a ⚠️ reaction to the original message. |
## Deployment Options
@@ -147,6 +149,13 @@ helm upgrade openab openab/openab \
--set agents.kiro.stt.baseUrl=https://api.groq.com/openai/v1
```
+```bash
+helm upgrade openab openab/openab \
+ --set agents.kiro.stt.enabled=true \
+ --set agents.kiro.stt.apiKey=gsk_xxx \
+ --set agents.kiro.stt.echoTranscript=true # opt in to transcript echo
+```
+
## Disabling STT
Omit the `[stt]` section entirely, or set:
@@ -161,6 +170,6 @@ When disabled, audio attachments are silently skipped with no impact on existing
## Technical Notes
- openab sends `response_format=json` in the transcription request to ensure the response is always parseable JSON. Some local whisper servers default to plain text output without this parameter.
-- The actual MIME type from the Discord attachment is passed through to the STT API (e.g. `audio/ogg`, `audio/mp4`, `audio/wav`).
+- The actual MIME type from the platform attachment is passed through to the STT API (e.g. `audio/ogg` for Discord and Feishu voice messages, `audio/mp4`, `audio/wav`).
- Environment variables in config values are expanded via `${VAR}` syntax (e.g. `api_key = "${GROQ_API_KEY}"`).
- The `api_key` field is auto-detected from the `GROQ_API_KEY` environment variable when using the default Groq endpoint. If you set a custom `base_url` (e.g. local server), auto-detect is disabled to avoid leaking the Groq key to unrelated endpoints — you must set `api_key` explicitly.
diff --git a/docs/telegram.md b/docs/telegram.md
index d7dd9ae09..b1f576750 100644
--- a/docs/telegram.md
+++ b/docs/telegram.md
@@ -168,6 +168,19 @@ explain VPC peering ← ignored in groups
DMs and replies within forum topics always trigger the agent (no @mention needed).
+### File Attachments (Inbound)
+
+The gateway downloads media from Telegram and stores it locally (`~/.openab/media/inbound/`). Core reads directly from disk — no base64 encoding overhead.
+
+| Type | Handling |
+|------|----------|
+| **Images** | Downloaded, resized (max 1200px), JPEG compressed, stored to filesystem. Agent sees the image. |
+| **Documents** | Text-based files (`.txt`, `.csv`, `.rs`, `.py`, etc.) up to 20MB read as UTF-8 and passed to agent. Binary files silently skipped. |
+| **Audio/Voice** | Downloaded and stored. If STT is enabled in Core, automatically transcribed and passed as text. |
+
+**Not supported (inbound):** video, stickers, animations (silently skipped).
+**Not supported (outbound):** bot cannot send images/files back to the user yet.
+
### Emoji reactions
The bot shows status reactions on your message as the agent works:
diff --git a/docs/wecom.md b/docs/wecom.md
new file mode 100644
index 000000000..ad8efec8f
--- /dev/null
+++ b/docs/wecom.md
@@ -0,0 +1,187 @@
+# WeCom (企业微信) Setup
+
+Connect a WeCom (Enterprise WeChat) bot to OpenAB via the Custom Gateway.
+
+```
+WeCom ──POST──▶ Gateway (:8080) ◀──WebSocket── OAB Pod
+ (OAB connects out)
+```
+
+## Prerequisites
+
+- A running OAB instance (with any ACP agent authenticated)
+- The Custom Gateway deployed ([gateway/README.md](../gateway/README.md))
+- A WeCom enterprise account with admin access
+
+## 1. Create a WeCom App
+
+1. Log in to [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame)
+2. Go to **应用管理** (App Management) → **自建** (Self-built) → **创建应用** (Create App)
+3. Fill in the app name and description, select visible scope
+4. After creation, note down:
+ - **AgentId** — on the app detail page
+ - **Secret** — click to view/copy on the app detail page
+5. Go to **我的企业** (My Enterprise) → copy the **企业ID** (Corp ID)
+
+## 2. Configure the Callback URL
+
+1. In the app detail page, scroll to **接收消息** (Receive Messages)
+2. Click **设置API接收** (Set API Receive)
+3. Fill in:
+ - **URL**: `https://your-gateway-host/webhook/wecom` (must be HTTPS)
+ - **Token**: click "随机获取" (Random Generate) or set your own
+ - **EncodingAESKey**: click "随机获取" (Random Generate) or set your own
+4. **Do NOT click Save yet** — you need the gateway running first to verify the URL
+
+## 3. Configure the Gateway
+
+Set the following environment variables:
+
+| Variable | Required | Description |
+|---|---|---|
+| `WECOM_CORP_ID` | Yes | Enterprise Corp ID (from My Enterprise page) |
+| `WECOM_AGENT_ID` | Yes | App Agent ID |
+| `WECOM_SECRET` | Yes | App Secret |
+| `WECOM_TOKEN` | Yes | Callback Token (from step 2) |
+| `WECOM_ENCODING_AES_KEY` | Yes | Callback EncodingAESKey (43 characters) |
+| `WECOM_WEBHOOK_PATH` | No | Webhook path (default: `/webhook/wecom`) |
+| `WECOM_STREAMING_ENABLED` | No | Stream replies via "thinking" placeholder + recall + resend (default: `false`). WeCom has no edit-message API; enabling this causes a brief client flicker during streaming. |
+| `WECOM_DEBOUNCE_SECS` | No | Quiet-period seconds before flushing buffered streamed text (default: `3`, minimum: `1` — `0` is silently ignored by Helm's truthy check and disables the buffer purpose) |
+
+```bash
+docker run -d --name openab-gateway \
+ -e WECOM_CORP_ID="ww1234567890abcdef" \
+ -e WECOM_AGENT_ID="1000002" \
+ -e WECOM_SECRET="your-app-secret" \
+ -e WECOM_TOKEN="your-callback-token" \
+ -e WECOM_ENCODING_AES_KEY="your-43-char-encoding-aes-key" \
+ -p 8080:8080 \
+ ghcr.io/openabdev/openab-gateway:latest
+```
+
+For Kubernetes with Helm, see [`charts/openab/values.yaml`](../charts/openab/values.yaml) — set values under `agents..gateway.wecom`.
+
+## 4. Verify the Callback URL
+
+Once the gateway is running with the correct env vars:
+
+1. Go back to the WeCom Admin Console → App → 接收消息 → 设置API接收
+2. Click **保存** (Save)
+3. WeCom will send a verification request to your URL — if the gateway decrypts and responds correctly, you'll see "保存成功" (Save Successful)
+
+If verification fails:
+- Check that the gateway is reachable over HTTPS
+- Verify `WECOM_TOKEN` and `WECOM_ENCODING_AES_KEY` match exactly what's shown in the WeCom console
+- Check gateway logs for errors
+
+## 5. Configure OAB
+
+```toml
+[gateway]
+url = "ws://openab-gateway:8080/ws"
+platform = "wecom"
+allow_all_channels = true
+allow_all_users = true
+
+[agent]
+command = "claude-agent-acp"
+args = []
+working_dir = "/home/node"
+env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" }
+
+[pool]
+max_sessions = 10
+```
+
+| Key | Required | Description |
+|---|---|---|
+| `url` | Yes | WebSocket URL of the gateway |
+| `platform` | No | Session key namespace (default: `wecom`) |
+| `allow_all_channels` | No | Allow messages from all channels (default: `false`) |
+| `allow_all_users` | No | Allow messages from all users (default: `false`) |
+
+## 6. Expose the Gateway (HTTPS)
+
+WeCom requires a publicly accessible HTTPS URL for callbacks.
+
+### Option A: Zeabur (one-click HTTPS for quick testing)
+
+Deploy the gateway to [Zeabur](https://zeabur.com) — HTTPS is automatically provisioned.
+
+### Option B: Cloudflare Tunnel
+
+```bash
+cloudflared tunnel --url http://localhost:8080
+```
+
+### Option C: Reverse proxy (production)
+
+Use nginx, Caddy, or a cloud load balancer with TLS termination pointing to the gateway's `:8080`.
+
+## 7. Set Trusted IP (Optional)
+
+For production, restrict the callback to WeCom's IP ranges:
+
+1. In the WeCom Admin Console → App → **企业可信IP** (Trusted IP)
+2. Add your gateway's public IP
+
+## Usage
+
+Send a direct message to the bot in the WeCom mobile or desktop app:
+
+```
+你好,帮我解释一下这段代码
+```
+
+The bot will reply directly in the same conversation.
+
+> **Note on group chats:** WeCom self-built enterprise apps only deliver **1:1 direct messages** to the callback URL. Group chat messages are not forwarded by this API path; group chat support would require the `appchat` API (not yet implemented). For group chat use cases, see the WeCom AI Bot WebSocket API as a future adapter.
+
+## Features
+
+| Feature | Status |
+|---|---|
+| Direct message (1:1) | ✅ |
+| Text message receive/reply | ✅ |
+| AES-256-CBC message decryption | ✅ |
+| Message deduplication | ✅ |
+| Auto-split long replies (2048 bytes) | ✅ |
+| Access token auto-refresh | ✅ |
+| Image receive | ✅ |
+| Text file receive | ✅ |
+| Streaming replies (thinking placeholder + debounce flush) | ✅ |
+| Group chat | ❌ Not supported (callback API limitation) |
+| Voice/video messages | Planned |
+| Markdown card replies | Planned |
+
+## Production Hardening
+
+The gateway does no application-level rate limiting on `/webhook/wecom`. Each request triggers an XML envelope parse, a SHA1 signature computation, and (if signature passes) AES-256-CBC decryption. A 5-minute timestamp freshness check rejects stale callbacks before any crypto runs, so old replays are cheap to drop, but fresh-but-invalid requests still consume CPU.
+
+Run the gateway behind a reverse proxy or load balancer that enforces rate limits at the IP / connection level:
+
+| Layer | Example |
+|---|---|
+| Edge / CDN | Cloudflare WAF rate limiting rules on `/webhook/wecom` |
+| Cloud LB | AWS ALB rate-based rules, GCP Cloud Armor |
+| Reverse proxy | nginx `limit_req_zone`, Caddy `rate_limit` directive |
+
+In addition, restrict the callback URL to WeCom's published IP ranges via the **企业可信IP** (Trusted IP) list in the WeCom Admin Console. This is the most effective control because all legitimate callbacks originate from those ranges.
+
+### Redact `corpsecret` from access logs
+
+WeCom's `gettoken` API mandates `corpsecret` as a query parameter (the protocol does not support a header alternative). The gateway itself does not log this URL, but if the gateway sits behind a reverse proxy with default access logging enabled, the secret will appear in access logs. Configure the proxy to redact query strings on `/cgi-bin/gettoken` outbound calls (or sanitize at log-shipping time).
+
+### Known limitations
+
+- **Streaming task lifetime on shutdown** — the optional streaming mode (`WECOM_STREAMING_ENABLED=true`) spawns one debounce task per in-flight reply. On SIGTERM these tasks are dropped by the tokio runtime; any text buffered but not yet flushed is lost. The agent will typically re-emit on the next interaction. If you need flush-on-shutdown semantics, keep streaming off (default) so each reply is sent synchronously.
+- **DedupeCache eviction is lazy** — entries are TTL-checked on lookup and bulk-evicted only when the cache reaches `DEDUPE_MAX_SIZE` (10K). For low-traffic deployments the HashMap can sit just below the cap with stale entries; max memory is bounded (~500 KB) and the dedup window itself is honored, so this does not affect correctness.
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| Callback verification fails | Token/EncodingAESKey mismatch | Double-check values match WeCom console exactly |
+| Bot receives but doesn't reply | Agent auth token not configured | Set `env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" }` in OAB config |
+| Intermittent "no response" | WeCom disabled callback after errors | Re-save callback config in WeCom console to re-verify |
+| "IP not in whitelist" on reply | Trusted IP not set | Add gateway IP to app's trusted IP list, or leave it empty for dev |
diff --git a/docs/xai-proxy.md b/docs/xai-proxy.md
new file mode 100644
index 000000000..3a2302c54
--- /dev/null
+++ b/docs/xai-proxy.md
@@ -0,0 +1,43 @@
+# xAI / SuperGrok Integration
+
+## Recommended: Native xAI OAuth (OpenCode ≥1.15.0)
+
+OpenCode now has **built-in xAI OAuth support** — no sidecar proxy needed.
+
+1. Run `/connect` inside OpenCode → select **xAI Grok OAuth (Headless / Remote / VPS)**
+2. Approve the device-code on any browser
+3. Select your model with `/models` (e.g. `grok-4.3`)
+
+OpenCode handles token storage and auto-refresh internally.
+
+---
+
+## Alternative: openab-auth-proxy sidecar
+
+For agents **without** native xAI OAuth (Hermes, custom agents), use
+`openab-auth-proxy` — a generic OAuth sidecar that defaults to xAI.
+
+```bash
+# Login (one-time)
+openab-auth-proxy login-device
+
+# Start proxy
+openab-auth-proxy serve --port 9090
+
+# Point any OpenAI-compatible client at the proxy
+export OPENAI_BASE_URL=http://127.0.0.1:9090/v1
+export OPENAI_API_KEY=dummy
+```
+
+See [docs/refarch/sidecar-proxy.md](refarch/sidecar-proxy.md) for the full
+architecture, Helm deployment, and custom provider configuration.
+
+## Comparison
+
+| | Native OAuth | openab-auth-proxy sidecar |
+|---|---|---|
+| **Requires** | OpenCode ≥1.15.0 | Any OpenAI-compatible agent |
+| **Extra container** | No | Yes |
+| **Token management** | Built into OpenCode | Proxy handles refresh |
+| **Multi-agent sharing** | Each agent needs own auth | Single proxy serves all |
+| **Custom providers** | xAI only | Any OIDC provider via TOML config |
diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock
index b0fa728bb..9a93e1098 100644
--- a/gateway/Cargo.lock
+++ b/gateway/Cargo.lock
@@ -1112,7 +1112,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openab-gateway"
-version = "0.1.0"
+version = "0.5.1"
dependencies = [
"aes",
"anyhow",
@@ -1125,15 +1125,18 @@ dependencies = [
"image",
"jsonwebtoken",
"prost",
+ "quick-xml",
"reqwest",
"serde",
"serde_json",
+ "sha1",
"sha2",
"subtle",
"tokio",
"tokio-tungstenite 0.21.0",
"tracing",
"tracing-subscriber",
+ "urlencoding",
"uuid",
"wiremock",
]
@@ -1274,6 +1277,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quick-xml"
+version = "0.37.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -2167,6 +2179,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "utf-8"
version = "0.7.6"
diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml
index 76746e0bc..60d13a6f7 100644
--- a/gateway/Cargo.toml
+++ b/gateway/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "openab-gateway"
-version = "0.4.0"
+version = "0.5.1"
edition = "2021"
[dependencies]
@@ -24,7 +24,10 @@ aes = "0.8"
cbc = "0.1"
prost = "0.13"
subtle = "2"
+sha1 = "0.10"
+quick-xml = "0.37"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
+urlencoding = "2"
[dev-dependencies]
wiremock = "0.6"
diff --git a/gateway/README.md b/gateway/README.md
index aa36cbf6a..79c492a28 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -67,6 +67,14 @@ url = "ws://gateway:8080/ws"
| `GOOGLE_CHAT_SA_KEY_FILE` | (optional) | Path to service account key JSON file (alternative to `SA_KEY_JSON`) |
| `GOOGLE_CHAT_ACCESS_TOKEN` | (optional) | Static OAuth2 access token (fallback, expires in 1 hour) |
| `GOOGLE_CHAT_WEBHOOK_PATH` | `/webhook/googlechat` | Webhook endpoint path |
+| `WECOM_CORP_ID` | (required*) | WeCom Corp ID — enables wecom adapter |
+| `WECOM_AGENT_ID` | (required*) | WeCom App Agent ID |
+| `WECOM_SECRET` | (required*) | WeCom App Secret |
+| `WECOM_TOKEN` | (required*) | Callback verification Token |
+| `WECOM_ENCODING_AES_KEY` | (required*) | Callback EncodingAESKey (43 chars) |
+| `WECOM_WEBHOOK_PATH` | `/webhook/wecom` | Webhook endpoint path |
+| `WECOM_STREAMING_ENABLED` | `false` | Enable thinking-placeholder + recall streaming (causes brief client flicker) |
+| `WECOM_DEBOUNCE_SECS` | `3` | Debounce quiet-period seconds before flushing buffered streamed text |
### Endpoints
@@ -76,6 +84,8 @@ url = "ws://gateway:8080/ws"
| `POST /webhook/line` | LINE webhook receiver |
| `POST /webhook/feishu` | Feishu webhook receiver (when `FEISHU_CONNECTION_MODE=webhook`) |
| `POST /webhook/googlechat` | Google Chat webhook receiver |
+| `GET /webhook/wecom` | WeCom callback URL verification |
+| `POST /webhook/wecom` | WeCom message callback receiver |
| `GET /ws` | WebSocket server (OAB connects here) |
| `GET /health` | Health check |
@@ -117,6 +127,10 @@ See [docs/feishu.md](../docs/feishu.md) for the full setup guide.
See [docs/google-chat.md](../docs/google-chat.md) for the full setup guide.
+### WeCom (企业微信)
+
+See [docs/wecom.md](../docs/wecom.md) for the full setup guide.
+
### Other Platforms
GitHub webhooks, CI/CD events, monitoring alerts — any HTTP event source can be added as a gateway adapter. See the ADR for the adapter interface.
diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs
index 922fae342..75cd45dd5 100644
--- a/gateway/src/adapters/feishu.rs
+++ b/gateway/src/adapters/feishu.rs
@@ -68,6 +68,20 @@ pub enum AllowBots {
All,
}
+/// Controls when the bot responds without @mention in threads.
+/// Mirrors Discord's `allow_user_messages` setting.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub enum AllowUsers {
+ /// Bot responds in threads it has participated in without @mention.
+ #[default]
+ Involved,
+ /// Always require @mention, even in participated threads.
+ Mentions,
+ /// Like Involved, but if another bot has also posted in the thread,
+ /// require @mention to avoid all bots responding.
+ MultibotMentions,
+}
+
#[derive(Debug, Clone)]
pub struct FeishuConfig {
pub app_id: String,
@@ -81,10 +95,20 @@ pub struct FeishuConfig {
pub allowed_users: Vec,
pub require_mention: bool,
pub allow_bots: AllowBots,
+ pub allow_user_messages: AllowUsers,
pub trusted_bot_ids: Vec,
pub max_bot_turns: u32,
pub dedupe_ttl_secs: u64,
pub message_limit: usize,
+ /// TTL for participated-thread cache entries (seconds). Threads older than
+ /// this are forgotten and require a fresh @mention to re-engage.
+ /// Set to 0 (via FEISHU_SESSION_TTL_HOURS=0) to disable participation
+ /// tracking entirely — all messages will require @mention.
+ /// Converted from `FEISHU_SESSION_TTL_HOURS` (user-facing, in hours) to seconds internally.
+ pub session_ttl_secs: u64,
+ /// Override the API base URL. Used in tests to point at a mock server.
+ /// Always None in production (not read from env).
+ pub api_base_override: Option,
}
impl FeishuConfig {
@@ -125,6 +149,16 @@ impl FeishuConfig {
_ => AllowBots::Off,
};
let trusted_bot_ids = parse_csv("FEISHU_TRUSTED_BOT_IDS");
+ let allow_user_messages = match std::env::var("FEISHU_ALLOW_USER_MESSAGES")
+ .unwrap_or_else(|_| "involved".into())
+ .to_lowercase()
+ .replace('-', "_")
+ .as_str()
+ {
+ "mentions" => AllowUsers::Mentions,
+ "multibot_mentions" => AllowUsers::MultibotMentions,
+ _ => AllowUsers::Involved,
+ };
let max_bot_turns = std::env::var("FEISHU_MAX_BOT_TURNS")
.ok()
.and_then(|v| v.parse().ok())
@@ -137,6 +171,11 @@ impl FeishuConfig {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4000);
+ let session_ttl_secs = std::env::var("FEISHU_SESSION_TTL_HOURS")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(24)
+ * 3600;
Some(Self {
app_id,
@@ -150,15 +189,21 @@ impl FeishuConfig {
allowed_users,
require_mention,
allow_bots,
+ allow_user_messages,
trusted_bot_ids,
max_bot_turns,
dedupe_ttl_secs,
message_limit,
+ session_ttl_secs,
+ api_base_override: None,
})
}
/// API base URL for the configured domain.
pub fn api_base(&self) -> String {
+ if let Some(ref base) = self.api_base_override {
+ return base.clone();
+ }
if self.domain == "lark" {
"https://open.larksuite.com".into()
} else {
@@ -188,6 +233,8 @@ mod event_types {
pub header: Option,
pub event: Option,
pub challenge: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
#[serde(rename = "type")]
pub event_type_field: Option,
}
@@ -195,6 +242,8 @@ mod event_types {
#[derive(Debug, Deserialize)]
pub struct FeishuEventHeader {
pub event_id: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
pub event_type: Option,
}
@@ -231,6 +280,8 @@ mod event_types {
pub struct FeishuMention {
pub key: Option,
pub id: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
pub name: Option,
}
@@ -242,10 +293,16 @@ mod event_types {
/// Parse a feishu im.message.receive_v1 event into a GatewayEvent.
/// Returns None if the event should be skipped (unsupported type, bot message, etc).
/// The Vec contains references to media that need async download.
+ ///
+ /// `bypass_mention_gating`: whether the bot should skip @mention requirement for this message.
+ /// This is the final computed result from mode-specific logic (detect_and_mark_multibot),
+ /// already accounting for the configured `allow_user_messages` mode.
+ /// Do NOT pass raw participation status here.
pub fn parse_message_event(
envelope: &FeishuEventEnvelope,
bot_open_id: Option<&str>,
config: &FeishuConfig,
+ bypass_mention_gating: bool,
) -> Option<(GatewayEvent, Vec)> {
let _header = envelope.header.as_ref()?;
let event = envelope.event.as_ref()?;
@@ -253,7 +310,7 @@ mod event_types {
let sender = event.sender.as_ref()?;
let msg_type = msg.message_type.as_deref().unwrap_or("text");
- if !matches!(msg_type, "text" | "image" | "file" | "post") {
+ if !matches!(msg_type, "text" | "image" | "file" | "post" | "audio") {
return None;
}
// Skip bot messages with explicit sender_type
@@ -341,6 +398,17 @@ mod event_types {
}];
(String::new(), mentions.1, refs)
}
+ "audio" => {
+ let file_key = content_json.get("file_key")?.as_str()?;
+ let mentions = extract_mentions(
+ "", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id,
+ );
+ let refs = vec![MediaRef::Audio {
+ message_id: message_id.to_string(),
+ file_key: file_key.to_string(),
+ }];
+ (String::new(), mentions.1, refs)
+ }
"post" => {
// Rich text: content is {"title":"...","content":[[{tag,text,...},{tag,image_key,...}]]}
let mut texts = Vec::new();
@@ -412,7 +480,14 @@ mod event_types {
// Gateway-side mention gating: in groups, skip if require_mention
// is true and bot is not mentioned (for human senders).
- if channel_type == "group" && !is_bot_sender && config.require_mention {
+ // Bypass: if bot has previously replied in this thread (participated),
+ // no @mention needed (like Discord's "involved" mode).
+ let in_thread = thread_id.is_some();
+ if channel_type == "group"
+ && !is_bot_sender
+ && config.require_mention
+ && !(in_thread && bypass_mention_gating)
+ {
if let Some(bot_id) = bot_open_id {
let bot_mentioned = mention_ids.iter().any(|id| id == bot_id);
if !bot_mentioned {
@@ -627,7 +702,14 @@ pub struct FeishuAdapter {
pub name_cache: Arc>>,
/// Per-channel bot turn counter. Key = chat_id, Value = (count, last_reset).
/// Human message resets count to 0. Prevents runaway bot-to-bot loops.
- pub bot_turns: Arc>>, // TODO: add TTL eviction for long-running deploys
+ pub bot_turns: Arc>>, // eviction: human msg resets; follow-up can add TTL like participated_threads
+ /// Positive-only cache: thread_id (root_id) → last_replied_at.
+ /// When bot has replied in a thread, subsequent messages in that thread
+ /// bypass @mention gating (like Discord's "involved" mode).
+ pub participated_threads: Arc>>,
+ /// Positive-only cache: thread_id → first_seen for threads where other bots
+ /// have posted. Used by multibot-mentions mode to require @mention.
+ pub multibot_threads: Arc>>,
pub client: reqwest::Client,
}
@@ -644,6 +726,8 @@ impl FeishuAdapter {
bot_open_id: Arc::new(RwLock::new(None)),
name_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
bot_turns: Arc::new(std::sync::Mutex::new(HashMap::new())),
+ participated_threads: Arc::new(std::sync::Mutex::new(HashMap::new())),
+ multibot_threads: Arc::new(std::sync::Mutex::new(HashMap::new())),
client: reqwest::Client::new(),
}
}
@@ -737,6 +821,8 @@ pub async fn start_websocket(
let client = adapter.client.clone();
let name_cache = adapter.name_cache.clone();
let bot_turns = adapter.bot_turns.clone();
+ let participated_threads = adapter.participated_threads.clone();
+ let multibot_threads = adapter.multibot_threads.clone();
let handle = tokio::spawn(async move {
let mut backoff_secs = 1u64;
@@ -751,6 +837,8 @@ pub async fn start_websocket(
&mut shutdown_rx,
&name_cache,
&bot_turns,
+ &participated_threads,
+ &multibot_threads,
)
.await;
@@ -781,6 +869,7 @@ pub async fn start_websocket(
}
/// Single WebSocket connection lifecycle.
+#[allow(clippy::too_many_arguments)]
async fn ws_connect_loop(
token_cache: &Arc,
bot_open_id_store: &Arc>>,
@@ -791,6 +880,8 @@ async fn ws_connect_loop(
shutdown_rx: &mut watch::Receiver,
name_cache: &Arc>>,
bot_turns: &Arc>>,
+ participated_threads: &Arc>>,
+ multibot_threads: &Arc>>,
) -> anyhow::Result<()> {
let api_base = config.api_base();
@@ -818,7 +909,7 @@ async fn ws_connect_loop(
Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => {
handle_ws_message(
&text, bot_open_id_store, dedupe, config, event_tx,
- name_cache, token_cache, client, bot_turns,
+ name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads,
).await;
}
Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(data))) => {
@@ -839,7 +930,7 @@ async fn ws_connect_loop(
if let Ok(text) = String::from_utf8(payload.clone()) {
handle_ws_message(
&text, bot_open_id_store, dedupe, config, event_tx,
- name_cache, token_cache, client, bot_turns,
+ name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads,
).await;
}
}
@@ -848,7 +939,7 @@ async fn ws_connect_loop(
ack.payload = Some(b"{\"code\":200}".to_vec());
let ack_bytes = ack.encode_to_vec();
let _ = ws_tx.send(
- tokio_tungstenite::tungstenite::Message::Binary(ack_bytes.into())
+ tokio_tungstenite::tungstenite::Message::Binary(ack_bytes)
).await;
}
}
@@ -869,6 +960,7 @@ async fn ws_connect_loop(
}
/// Process a single WebSocket text message.
+#[allow(clippy::too_many_arguments)]
async fn handle_ws_message(
text: &str,
bot_open_id_store: &Arc>>,
@@ -879,6 +971,8 @@ async fn handle_ws_message(
token_cache: &Arc,
client: &reqwest::Client,
bot_turns: &Arc>>,
+ participated_threads: &Arc>>,
+ multibot_threads: &Arc>>,
) {
let envelope: FeishuEventEnvelope = match serde_json::from_str(text) {
Ok(e) => e,
@@ -914,7 +1008,16 @@ async fn handle_ws_message(
let bot_id = bot_open_id_store.read().await;
let bot_id_ref = bot_id.as_deref();
- if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config) {
+ // Check if the message is in a thread where bot has previously replied,
+ // respecting the allow_user_messages mode:
+ // - Involved (default): bypass @mention if participated
+ // - MultibotMentions: bypass only if participated AND no other bot in thread
+ // - Mentions: never bypass
+ let bypass_mention = detect_and_mark_multibot(
+ &envelope, bot_id_ref, config, participated_threads, multibot_threads,
+ );
+
+ if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, bypass_mention) {
// Also dedupe by message_id
if dedupe.is_duplicate(&gateway_event.message_id) {
return;
@@ -936,6 +1039,8 @@ async fn handle_ws_message(
);
return;
}
+ // (Feishu doesn't push bot messages to other bots' WebSocket,
+ // so multibot detection is done via mentions instead — see below.)
} else {
// Human message resets bot turn counter
turns.remove(channel_id.as_str());
@@ -961,6 +1066,9 @@ async fn handle_ws_message(
MediaRef::File { message_id, file_key, file_name } => {
download_feishu_file(client, &api_base, &token, message_id, file_key, file_name).await
}
+ MediaRef::Audio { message_id, file_key } => {
+ download_feishu_audio(client, &api_base, &token, message_id, file_key).await
+ }
};
if let Some(att) = attachment {
gateway_event.content.attachments.push(att);
@@ -1080,8 +1188,8 @@ fn markdown_to_post(md: &str) -> serde_json::Value {
let line = raw_lines[li];
// Detect fenced code block
let trimmed = line.trim_start();
- if trimmed.starts_with("```") {
- let lang = trimmed[3..].trim().to_string();
+ if let Some(after_fence) = trimmed.strip_prefix("```") {
+ let lang = after_fence.trim().to_string();
let mut code = String::new();
li += 1;
while li < raw_lines.len() {
@@ -1154,9 +1262,7 @@ fn parse_inline(line: &str) -> Vec {
}
if close_ticks == ticks {
// Found matching close — content between is literal
- for j in i..end {
- buf.push(chars[j]);
- }
+ buf.extend(chars[i..end].iter().copied());
i = end + close_ticks;
break 'outer;
}
@@ -1167,9 +1273,7 @@ fn parse_inline(line: &str) -> Vec {
}
if end >= len {
// No matching close — treat backticks as literal
- for j in i..len {
- buf.push(chars[j]);
- }
+ buf.extend(chars[i..len].iter().copied());
i = len;
}
continue;
@@ -1194,9 +1298,7 @@ fn parse_inline(line: &str) -> Vec {
}
if close_run == run {
// Found matching close — strip both, keep inner text
- for j in after..scan {
- buf.push(chars[j]);
- }
+ buf.extend(chars[after..scan].iter().copied());
i = scan + close_run;
found_close = true;
break;
@@ -1266,6 +1368,7 @@ fn try_parse_link(chars: &[char], start: usize) -> Option<(String, String, usize
pub enum MediaRef {
Image { message_id: String, image_key: String },
File { message_id: String, file_key: String, file_name: String },
+ Audio { message_id: String, file_key: String },
}
const IMAGE_MAX_DIMENSION_PX: u32 = 1200;
@@ -1346,15 +1449,15 @@ pub async fn download_feishu_image(
return None;
}
};
- use base64::Engine;
- let data = base64::engine::general_purpose::STANDARD.encode(&compressed);
+ let path = crate::store::store_media(&compressed).await?;
let ext = if mime == "image/gif" { "gif" } else { "jpg" };
Some(crate::schema::Attachment {
attachment_type: "image".into(),
filename: format!("{}.{}", image_key, ext),
mime_type: mime,
- data,
+ data: String::new(),
size: compressed.len() as u64,
+ path: Some(path),
})
}
@@ -1408,15 +1511,71 @@ pub async fn download_feishu_file(
tracing::warn!(file_name, size = bytes.len(), "feishu file exceeds 512KB limit");
return None;
}
- let text = String::from_utf8_lossy(&bytes);
- use base64::Engine;
- let data = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
+ let path = crate::store::store_media(&bytes).await?;
Some(crate::schema::Attachment {
attachment_type: "text_file".into(),
filename: file_name.to_string(),
mime_type: "text/plain".into(),
- data,
+ data: String::new(),
size: bytes.len() as u64,
+ path: Some(path),
+ })
+}
+
+const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB (Whisper API limit)
+
+/// Download a Feishu audio message by message_id + file_key → base64 Attachment.
+pub async fn download_feishu_audio(
+ client: &reqwest::Client,
+ api_base: &str,
+ token: &str,
+ message_id: &str,
+ file_key: &str,
+) -> Option {
+ use urlencoding::encode;
+ let url = format!(
+ "{}/open-apis/im/v1/messages/{}/resources/{}?type=file",
+ api_base, encode(message_id), encode(file_key)
+ );
+ let resp = match client.get(&url).bearer_auth(token).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!(file_key, error = %e, "feishu audio download failed");
+ return None;
+ }
+ };
+ if !resp.status().is_success() {
+ tracing::warn!(file_key, status = %resp.status(), "feishu audio download failed");
+ return None;
+ }
+ let content_type = resp
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("audio/ogg")
+ .to_string();
+ if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) {
+ if let Ok(size) = cl.to_str().unwrap_or("0").parse::() {
+ if size > AUDIO_MAX_DOWNLOAD {
+ tracing::warn!(file_key, size, "feishu audio exceeds 25MB limit");
+ return None;
+ }
+ }
+ }
+ let bytes = resp.bytes().await.ok()?;
+ if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD {
+ tracing::warn!(file_key, size = bytes.len(), "feishu audio exceeds 25MB limit");
+ return None;
+ }
+ tracing::debug!(file_key, size = bytes.len(), "feishu audio downloaded");
+ let path = crate::store::store_media(&bytes).await?;
+ Some(crate::schema::Attachment {
+ attachment_type: "audio".into(),
+ filename: format!("{}.ogg", file_key),
+ mime_type: content_type,
+ data: String::new(),
+ size: bytes.len() as u64,
+ path: Some(path),
})
}
@@ -1639,6 +1798,144 @@ async fn remove_reaction(adapter: &FeishuAdapter, message_id: &str, emoji: &str)
// Reply handler
// ---------------------------------------------------------------------------
+/// Check if the bot has participated in the thread referenced by this envelope.
+/// Returns `true` if the message is in a thread and that thread has a valid
+/// (non-expired) participation entry in the cache.
+fn check_thread_participated(
+ envelope: &FeishuEventEnvelope,
+ cache: &Arc>>,
+ session_ttl_secs: u64,
+) -> bool {
+ envelope
+ .event
+ .as_ref()
+ .and_then(|e| e.message.as_ref())
+ .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref()))
+ .map(|tid| {
+ // Intentionally recover from poisoned mutex — cache data loss is acceptable
+ // and preferable to panicking the gateway.
+ let c = cache.lock().unwrap_or_else(|e| e.into_inner());
+ c.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < session_ttl_secs)
+ })
+ .unwrap_or(false)
+}
+
+/// Max entries before eviction. Shared by both `participated_threads` and
+/// `multibot_threads` caches — they have the same cardinality (one entry per
+/// active thread) so a single limit is appropriate for both.
+const PARTICIPATION_CACHE_MAX: usize = 1000;
+
+/// Detect if a message @mentions another bot in a participated thread, and if
+/// so, mark the thread in the multibot cache. Returns whether @mention gating
+/// should be bypassed, respecting the configured `allow_user_messages` mode.
+///
+/// This consolidates the duplicated multibot detection logic used by both the
+/// WebSocket and webhook paths.
+fn detect_and_mark_multibot(
+ envelope: &FeishuEventEnvelope,
+ bot_open_id: Option<&str>,
+ config: &FeishuConfig,
+ participated_threads: &Arc>>,
+ multibot_threads: &Arc>>,
+) -> bool {
+ let self_participated = check_thread_participated(
+ envelope, participated_threads, config.session_ttl_secs,
+ );
+
+ let thread_id_for_check = envelope
+ .event
+ .as_ref()
+ .and_then(|e| e.message.as_ref())
+ .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref()));
+
+ // Early multibot detection: if a message in a participated thread @mentions
+ // another bot, mark the thread as multibot immediately.
+ if let Some(tid) = thread_id_for_check {
+ if self_participated {
+ let mentions = envelope
+ .event
+ .as_ref()
+ .and_then(|e| e.message.as_ref())
+ .and_then(|m| m.mentions.as_ref());
+ if let Some(mention_list) = mentions {
+ let bot_self_id = bot_open_id.unwrap_or("");
+ let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| {
+ m.id.as_ref().and_then(|id| id.open_id.as_deref())
+ }).collect();
+
+ let mentions_other_bot = if !config.trusted_bot_ids.is_empty() {
+ mention_ids.iter().any(|oid| {
+ config.trusted_bot_ids.iter().any(|bid| bid == oid)
+ })
+ } else if !config.allowed_users.is_empty() {
+ mention_ids.iter().any(|oid| {
+ *oid != bot_self_id && !config.allowed_users.iter().any(|u| u == oid)
+ })
+ } else {
+ false
+ };
+
+ if mentions_other_bot {
+ info!(thread_id = %tid, "multibot thread detected via @mention");
+ let mut cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner());
+ cache.entry(tid.to_string()).or_insert_with(Instant::now);
+ if cache.len() > PARTICIPATION_CACHE_MAX {
+ cache.retain(|_, ts| ts.elapsed().as_secs() < config.session_ttl_secs);
+ }
+ }
+ }
+ }
+ }
+
+ // Compute bypass_mention_gating based on mode
+ match config.allow_user_messages {
+ AllowUsers::Mentions => false,
+ AllowUsers::Involved => self_participated,
+ AllowUsers::MultibotMentions => {
+ if !self_participated {
+ false
+ } else {
+ thread_id_for_check
+ .map(|tid| {
+ let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner());
+ cache
+ .get(tid)
+ .is_none_or(|ts| ts.elapsed().as_secs() >= config.session_ttl_secs)
+ })
+ .unwrap_or(true)
+ }
+ }
+ }
+}
+
+/// Record that the bot has participated in a thread. Evicts oldest entries
+/// when the cache exceeds PARTICIPATION_CACHE_MAX.
+fn record_participation(
+ cache: &Arc>>,
+ thread_id: &str,
+ session_ttl_secs: u64,
+) {
+ if session_ttl_secs == 0 {
+ return; // Participation tracking disabled
+ }
+ // Intentionally recover from poisoned mutex — cache data loss is acceptable
+ // and preferable to panicking the gateway.
+ let mut map = cache.lock().unwrap_or_else(|e| e.into_inner());
+ map.insert(thread_id.to_string(), Instant::now());
+ // Evict if over capacity: first drop expired entries, then oldest half if still over
+ if map.len() > PARTICIPATION_CACHE_MAX {
+ map.retain(|_, ts| ts.elapsed().as_secs() < session_ttl_secs);
+ if map.len() > PARTICIPATION_CACHE_MAX {
+ let mut entries: Vec<_> = map.iter().map(|(k, v)| (k.clone(), *v)).collect();
+ entries.sort_by_key(|(_, ts)| *ts);
+ let evict_count = entries.len() / 2;
+ for (k, _) in entries.into_iter().take(evict_count) {
+ map.remove(&k);
+ }
+ }
+ }
+}
+
pub async fn handle_reply(
reply: &GatewayReply,
adapter: &FeishuAdapter,
@@ -1691,6 +1988,9 @@ pub async fn handle_reply(
let api_base = adapter.config.api_base();
let text = &reply.content.text;
let limit = adapter.config.message_limit;
+ // quote_message_id (agent-controlled reply-to) takes priority over thread_id
+ let reply_target = reply.quote_message_id.as_deref()
+ .or(reply.channel.thread_id.as_deref());
let thread_id = reply.channel.thread_id.as_deref();
// Split long messages; store sent message_ids in dedupe to prevent
@@ -1698,9 +1998,21 @@ pub async fn handle_reply(
// Use post (rich text) format for markdown rendering.
// When in a thread (thread_id present), use reply API to stay in the same thread.
if text.len() <= limit {
- match send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await {
+ let result = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, text).await;
+ // Fallback: if quote_message_id caused failure, retry without it
+ let result = if result.is_none() && reply.quote_message_id.is_some() {
+ tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "reply-to failed, falling back to plain send");
+ send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await
+ } else {
+ result
+ };
+ match result {
Some(msg_id) => {
adapter.dedupe.is_duplicate(&msg_id);
+ // Record thread participation for mention bypass
+ if let Some(tid) = thread_id {
+ record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs);
+ }
// Send response with message_id back to OAB core (for streaming edit)
if let Some(ref req_id) = reply.request_id {
let resp = crate::schema::GatewayResponse {
@@ -1734,9 +2046,26 @@ pub async fn handle_reply(
}
}
} else {
+ let mut sent_any = false;
for chunk in split_text(text, limit) {
- if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await {
+ if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, chunk).await {
adapter.dedupe.is_duplicate(&msg_id);
+ sent_any = true;
+ }
+ }
+ // Fallback: if quote_message_id caused all chunks to fail, retry without it
+ if !sent_any && reply.quote_message_id.is_some() {
+ tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "chunked reply-to failed, falling back to plain send");
+ for chunk in split_text(text, limit) {
+ if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await {
+ adapter.dedupe.is_duplicate(&msg_id);
+ sent_any = true;
+ }
+ }
+ }
+ if sent_any {
+ if let Some(tid) = thread_id {
+ record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs);
}
}
}
@@ -2012,7 +2341,13 @@ pub async fn webhook(
let bot_id = feishu.bot_open_id.read().await;
let bot_id_ref = bot_id.as_deref();
- if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config) {
+ // Check participated threads and multibot detection for mention bypass
+ let bypass_mention = detect_and_mark_multibot(
+ &envelope, bot_id_ref, &feishu.config,
+ &feishu.participated_threads, &feishu.multibot_threads,
+ );
+
+ if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, bypass_mention) {
if !feishu.dedupe.is_duplicate(&gateway_event.message_id) {
let name = resolve_user_name(
&gateway_event.sender.id, &feishu.name_cache, &feishu.token_cache,
@@ -2033,6 +2368,9 @@ pub async fn webhook(
MediaRef::File { message_id, file_key, file_name } => {
download_feishu_file(&feishu.client, &api_base, &token, message_id, file_key, file_name).await
}
+ MediaRef::Audio { message_id, file_key } => {
+ download_feishu_audio(&feishu.client, &api_base, &token, message_id, file_key).await
+ }
};
if let Some(att) = attachment {
gateway_event.content.attachments.push(att);
@@ -2082,10 +2420,13 @@ mod tests {
allowed_users: vec![],
require_mention: true,
allow_bots: AllowBots::Off,
+ allow_user_messages: AllowUsers::Involved,
trusted_bot_ids: vec![],
max_bot_turns: 20,
dedupe_ttl_secs: 300,
message_limit: 4000,
+ session_ttl_secs: 86400,
+ api_base_override: None,
}
}
@@ -2304,7 +2645,7 @@ mod tests {
fn parse_dm_text() {
let env = make_envelope("p2p", "hello", "ou_user1", None);
let cfg = test_config();
- let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap();
+ let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap();
assert_eq!(evt.platform, "feishu");
assert_eq!(evt.channel.channel_type, "direct");
assert_eq!(evt.channel.id, "oc_chat1");
@@ -2324,7 +2665,7 @@ mod tests {
}];
let env = make_envelope("group", "@_user_1 explain VPC", "ou_user1", Some(mentions));
let cfg = test_config();
- let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap();
+ let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap();
assert_eq!(evt.channel.channel_type, "group");
assert_eq!(evt.content.text, "explain VPC");
assert_eq!(evt.mentions, vec!["ou_bot"]);
@@ -2335,7 +2676,7 @@ mod tests {
let env = make_envelope("group", "just chatting", "ou_user1", None);
let cfg = test_config(); // require_mention = true
// Gateway-side mention gating: group message without bot mention is filtered
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}
#[test]
@@ -2343,7 +2684,7 @@ mod tests {
let env = make_envelope("group", "just chatting", "ou_user1", None);
let mut cfg = test_config();
cfg.require_mention = false;
- let evt = parse_message_event(&env, Some("ou_bot"), &cfg);
+ let evt = parse_message_event(&env, Some("ou_bot"), &cfg, false);
assert!(evt.is_some());
}
@@ -2352,14 +2693,14 @@ mod tests {
let mut env = make_envelope("p2p", "hello", "ou_bot", None);
env.event.as_mut().unwrap().sender.as_mut().unwrap().sender_type = Some("bot".into());
let cfg = test_config();
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}
#[test]
fn parse_skips_empty_text() {
let env = make_envelope("p2p", " ", "ou_user1", None);
let cfg = test_config();
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}
#[test]
@@ -2367,14 +2708,14 @@ mod tests {
let mut env = make_envelope("p2p", "hello", "ou_user1", None);
env.event.as_mut().unwrap().message.as_mut().unwrap().message_type = Some("sticker".into());
let cfg = test_config();
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}
#[test]
fn parse_skips_self_message() {
let env = make_envelope("p2p", "hello", "ou_bot", None);
let cfg = test_config();
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}
// --- Dedupe tests ---
@@ -2506,7 +2847,7 @@ mod tests {
}];
let env = make_envelope("group", "@_user_1 tell me about @_user_1 patterns", "ou_user1", Some(mentions));
let cfg = test_config();
- let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap();
+ let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap();
// Only first @_user_1 removed, second preserved
assert!(evt.content.text.contains("@_user_1"));
}
@@ -2518,7 +2859,7 @@ mod tests {
let env = make_envelope("p2p", "hello", "ou_stranger", None);
let mut cfg = test_config();
cfg.allowed_users = vec!["ou_vip".into()];
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}
#[test]
@@ -2526,7 +2867,7 @@ mod tests {
let env = make_envelope("p2p", "hello", "ou_vip", None);
let mut cfg = test_config();
cfg.allowed_users = vec!["ou_vip".into()];
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_some());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_some());
}
// --- allowed_groups filtering ---
@@ -2541,7 +2882,7 @@ mod tests {
let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions));
let mut cfg = test_config();
cfg.allowed_groups = vec!["oc_other".into()]; // oc_chat1 not in list
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
}
#[test]
@@ -2554,7 +2895,7 @@ mod tests {
let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions));
let mut cfg = test_config();
cfg.allowed_groups = vec!["oc_chat1".into()];
- assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_some());
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_some());
}
// --- Token TTL from API response ---
@@ -2611,7 +2952,7 @@ mod tests {
let mut env = make_envelope("p2p", "reply", "ou_user1", None);
env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("om_root".into());
let cfg = test_config();
- let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap();
+ let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap();
assert_eq!(evt.channel.thread_id, Some("om_root".into()));
}
@@ -2620,7 +2961,7 @@ mod tests {
let mut env = make_envelope("p2p", "reply", "ou_user1", None);
env.event.as_mut().unwrap().message.as_mut().unwrap().parent_id = Some("om_parent".into());
let cfg = test_config();
- let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap();
+ let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap();
assert_eq!(evt.channel.thread_id, Some("om_parent".into()));
}
@@ -2637,4 +2978,221 @@ mod tests {
fn emoji_mapping_unknown() {
assert_eq!(emoji_to_feishu_reaction("🎉"), None);
}
+
+ // --- Participated thread tests ---
+
+ #[test]
+ fn participated_thread_bypasses_mention_gating() {
+ let cfg = test_config(); // require_mention = true
+ // Build envelope with root_id (in a thread)
+ let mut env = make_envelope("group", "Hello", "ou_user1", None);
+ env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_123".into());
+ // Without participation: no @mention → None
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
+ // With participation: no @mention → Some (bypass)
+ let result = parse_message_event(&env, Some("ou_bot"), &cfg, true);
+ assert!(result.is_some());
+ let (evt, _) = result.unwrap();
+ assert_eq!(evt.channel.thread_id.as_deref(), Some("root_123"));
+ }
+
+ #[test]
+ fn participated_no_effect_without_thread() {
+ let cfg = test_config(); // require_mention = true
+ // Message in main channel (no thread_id) — participated flag doesn't help
+ let env = make_envelope("group", "Hello", "ou_user1", None);
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, true).is_none());
+ }
+
+ #[test]
+ fn record_participation_and_eviction() {
+ let cache = Arc::new(std::sync::Mutex::new(HashMap::new()));
+ // Record a thread
+ record_participation(&cache, "thread_1", 86400);
+ assert_eq!(cache.lock().unwrap().len(), 1);
+ // Fill beyond PARTICIPATION_CACHE_MAX
+ for i in 0..PARTICIPATION_CACHE_MAX + 10 {
+ record_participation(&cache, &format!("thread_{i}"), 86400);
+ }
+ // After eviction, should be roughly half
+ assert!(cache.lock().unwrap().len() <= PARTICIPATION_CACHE_MAX);
+ }
+
+ // --- Multibot-mentions mode tests ---
+
+ #[test]
+ fn multibot_mentions_mode_bypasses_when_single_bot() {
+ let mut cfg = test_config();
+ cfg.allow_user_messages = AllowUsers::MultibotMentions;
+ let mut env = make_envelope("group", "Hello", "ou_user1", None);
+ env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into());
+ // participated + no other bot → bypass_mention_gating=true
+ let result = parse_message_event(&env, Some("ou_bot"), &cfg, true);
+ assert!(result.is_some());
+ }
+
+ #[test]
+ fn multibot_mentions_mode_requires_mention_when_not_participated() {
+ let mut cfg = test_config();
+ cfg.allow_user_messages = AllowUsers::MultibotMentions;
+ let mut env = make_envelope("group", "Hello", "ou_user1", None);
+ env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into());
+ // not participated → bypass_mention_gating=false
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
+ }
+
+ #[test]
+ fn mentions_mode_never_bypasses() {
+ let mut cfg = test_config();
+ cfg.allow_user_messages = AllowUsers::Mentions;
+ let mut env = make_envelope("group", "Hello", "ou_user1", None);
+ env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_789".into());
+ // Even with bypass_mention_gating=true, Mentions mode never bypasses
+ // (caller would pass false because Mentions mode always returns false)
+ assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none());
+ }
+
+ #[test]
+ fn quote_message_id_takes_priority_over_thread_id() {
+ use crate::schema::{GatewayReply, ReplyChannel, Content};
+ let reply = GatewayReply {
+ schema: "openab.gateway.reply.v1".into(),
+ reply_to: "evt_123".into(),
+ platform: "feishu".into(),
+ channel: ReplyChannel {
+ id: "chat_123".into(),
+ thread_id: Some("om_root".into()),
+ },
+ content: Content {
+ content_type: "text".into(),
+ text: "hello".into(),
+ attachments: vec![],
+ },
+ command: None,
+ request_id: None,
+ quote_message_id: Some("om_specific".into()),
+ };
+ // quote_message_id should take priority
+ let reply_target = reply.quote_message_id.as_deref()
+ .or(reply.channel.thread_id.as_deref());
+ assert_eq!(reply_target, Some("om_specific"));
+ }
+
+ #[test]
+ fn reply_target_falls_back_to_thread_id_when_no_quote() {
+ use crate::schema::{GatewayReply, ReplyChannel, Content};
+ let reply = GatewayReply {
+ schema: "openab.gateway.reply.v1".into(),
+ reply_to: "evt_123".into(),
+ platform: "feishu".into(),
+ channel: ReplyChannel {
+ id: "chat_123".into(),
+ thread_id: Some("om_root".into()),
+ },
+ content: Content {
+ content_type: "text".into(),
+ text: "hello".into(),
+ attachments: vec![],
+ },
+ command: None,
+ request_id: None,
+ quote_message_id: None,
+ };
+ let reply_target = reply.quote_message_id.as_deref()
+ .or(reply.channel.thread_id.as_deref());
+ assert_eq!(reply_target, Some("om_root"));
+ }
+
+ #[test]
+ fn reply_target_is_none_when_both_absent() {
+ use crate::schema::{GatewayReply, ReplyChannel, Content};
+ let reply = GatewayReply {
+ schema: "openab.gateway.reply.v1".into(),
+ reply_to: "evt_123".into(),
+ platform: "feishu".into(),
+ channel: ReplyChannel {
+ id: "chat_123".into(),
+ thread_id: None,
+ },
+ content: Content {
+ content_type: "text".into(),
+ text: "hello".into(),
+ attachments: vec![],
+ },
+ command: None,
+ request_id: None,
+ quote_message_id: None,
+ };
+ let reply_target = reply.quote_message_id.as_deref()
+ .or(reply.channel.thread_id.as_deref());
+ assert_eq!(reply_target, None);
+ }
+
+ #[tokio::test]
+ async fn quote_message_id_fallback_on_reply_failure() {
+ // Tests the actual handle_reply fallback path: when quote_message_id
+ // is set and the reply API fails, handle_reply retries as plain send.
+ let server = MockServer::start().await;
+
+ // Token endpoint
+ Mock::given(method("POST"))
+ .and(path("/open-apis/auth/v3/tenant_access_token/internal"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "code": 0,
+ "tenant_access_token": "t-test",
+ "expire": 7200
+ })))
+ .mount(&server)
+ .await;
+
+ // Reply API returns 400 (invalid quote_message_id)
+ Mock::given(method("POST"))
+ .and(path("/open-apis/im/v1/messages/om_invalid/reply"))
+ .respond_with(ResponseTemplate::new(400).set_body_string("invalid message_id"))
+ .expect(1)
+ .named("reply_api_fail")
+ .mount(&server)
+ .await;
+
+ // Plain send endpoint succeeds (fallback path)
+ Mock::given(method("POST"))
+ .and(path("/open-apis/im/v1/messages"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "code": 0,
+ "data": {"message_id": "om_fallback_ok"}
+ })))
+ .expect(1)
+ .named("plain_send_fallback")
+ .mount(&server)
+ .await;
+
+ let mut config = test_config();
+ config.api_base_override = Some(server.uri());
+ let adapter = FeishuAdapter::new(config);
+
+ let (event_tx, _rx) = tokio::sync::broadcast::channel(16);
+
+ let reply = crate::schema::GatewayReply {
+ schema: "openab.gateway.reply.v1".into(),
+ reply_to: "evt_123".into(),
+ platform: "feishu".into(),
+ channel: crate::schema::ReplyChannel {
+ id: "oc_chat1".into(),
+ thread_id: None,
+ },
+ content: crate::schema::Content {
+ content_type: "text".into(),
+ text: "hello from fallback test".into(),
+ attachments: vec![],
+ },
+ command: None,
+ request_id: None,
+ quote_message_id: Some("om_invalid".into()),
+ };
+
+ handle_reply(&reply, &adapter, &event_tx).await;
+ // wiremock expect(1) on both mocks verifies:
+ // 1. Reply API was called (and failed)
+ // 2. Plain send was called (fallback triggered by quote_message_id.is_some() guard)
+ }
}
diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs
index 68759e026..93c0c8f8e 100644
--- a/gateway/src/adapters/googlechat.rs
+++ b/gateway/src/adapters/googlechat.rs
@@ -12,11 +12,35 @@ use tracing::{error, info, warn};
pub const GOOGLE_CHAT_API_BASE: &str = "https://chat.googleapis.com/v1";
const GOOGLE_CHAT_MESSAGE_LIMIT: usize = 4096;
-// --- Google Chat types (v2 envelope format) ---
+const IMAGE_MAX_DIMENSION_PX: u32 = 1200;
+const IMAGE_JPEG_QUALITY: u8 = 75;
+const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; // 10 MB
+const FILE_MAX_DOWNLOAD: u64 = 512 * 1024; // 512 KB
+const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB
+/// Per-request timeout for Google Chat Media API downloads. Prevents a hung
+/// connection from blocking the spawned download task indefinitely.
+const MEDIA_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
+/// Cap on text file attachments per message (matches Discord/Slack).
+const TEXT_FILE_COUNT_CAP: usize = 5;
+/// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB).
+const TEXT_TOTAL_CAP: u64 = 1024 * 1024;
+
+// --- Google Chat types ---
+//
+// Google Chat delivers webhooks in two shapes depending on the App's
+// Connection settings in the Cloud Console:
+// - HTTP endpoint URL mode: top-level fields (message, user, space, ...)
+// - Pub/Sub mode: wrapped under `chat.messagePayload`
+// Both are supported via the optional fields below; the handler prefers
+// the wrapped form and falls back to top-level when `chat` is absent.
#[derive(Debug, Deserialize)]
pub struct GoogleChatEnvelope {
pub chat: Option,
+ // HTTP endpoint URL top-level fields (used when `chat` is None)
+ pub message: Option,
+ pub user: Option,
+ pub space: Option,
}
#[derive(Debug, Deserialize)]
@@ -42,6 +66,52 @@ pub struct GoogleChatMessage {
pub sender: Option,
pub thread: Option,
pub space: Option,
+ #[serde(default)]
+ pub attachment: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GoogleChatAttachment {
+ #[allow(dead_code)]
+ pub name: Option,
+ pub content_name: Option,
+ pub content_type: Option,
+ pub source: Option,
+ pub attachment_data_ref: Option,
+ #[allow(dead_code)]
+ pub drive_data_ref: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AttachmentDataRef {
+ pub resource_name: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[allow(dead_code)]
+pub struct DriveDataRef {
+ pub drive_file_id: Option,
+}
+
+/// Reference to media that needs async download after webhook parse.
+#[derive(Debug, Clone)]
+pub enum GoogleChatMediaRef {
+ Image {
+ resource_name: String,
+ content_name: String,
+ },
+ File {
+ resource_name: String,
+ content_name: String,
+ },
+ Audio {
+ resource_name: String,
+ content_name: String,
+ content_type: String,
+ },
}
#[derive(Debug, Deserialize)]
@@ -64,6 +134,8 @@ pub struct GoogleChatSpace {
pub name: String,
#[serde(rename = "type")]
pub space_type: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
pub space_type_renamed: Option,
}
@@ -71,20 +143,20 @@ pub struct GoogleChatSpace {
const GOOGLE_CHAT_ISSUER: &str = "https://accounts.google.com";
const GOOGLE_CHAT_JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs";
-const GOOGLE_CHAT_EMAIL_SUFFIX: &str = "@gcp-sa-gsuiteaddons.iam.gserviceaccount.com";
+const GOOGLE_CHAT_SIGNER_EMAIL: &str = "chat@system.gserviceaccount.com";
const JWKS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
-/// Verify the JWT's `email` claim belongs to a Google Chat service account.
-/// Google Chat webhooks use `service-{PROJECT_NUMBER}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`.
+/// Verify the JWT's `email` claim belongs to Google Chat.
+/// HTTP endpoint URL webhooks are signed by `chat@system.gserviceaccount.com`.
/// Without this check, any Google-issued ID token would be accepted.
fn verify_email_claim(claims: &serde_json::Value) -> Result<(), String> {
let email = claims
.get("email")
.and_then(|v| v.as_str())
.ok_or("missing email claim")?;
- if !email.ends_with(GOOGLE_CHAT_EMAIL_SUFFIX) {
+ if email != GOOGLE_CHAT_SIGNER_EMAIL {
return Err(format!(
- "email claim mismatch: expected *{GOOGLE_CHAT_EMAIL_SUFFIX}, got {email}"
+ "email claim mismatch: expected {GOOGLE_CHAT_SIGNER_EMAIL}, got {email}"
));
}
Ok(())
@@ -423,13 +495,20 @@ pub async fn webhook(
}
};
- let Some(chat) = envelope.chat else {
- return empty_json_response();
- };
- let Some(payload) = chat.message_payload else {
- return empty_json_response();
+ // Try the Pub/Sub `chat`-wrapped shape first, then fall back to the
+ // HTTP endpoint URL top-level shape.
+ let (msg_opt, top_user, top_space) = if let Some(chat) = envelope.chat {
+ let user = chat.user;
+ let (msg, space) = match chat.message_payload {
+ Some(p) => (p.message, p.space),
+ None => (None, None),
+ };
+ (msg, user, space)
+ } else {
+ (envelope.message, envelope.user, envelope.space)
};
- let Some(ref msg) = payload.message else {
+
+ let Some(ref msg) = msg_opt else {
return empty_json_response();
};
@@ -438,12 +517,16 @@ pub async fn webhook(
.as_deref()
.or(msg.text.as_deref())
.unwrap_or("");
- if text.trim().is_empty() {
+
+ let media_refs = parse_attachments(&msg.attachment);
+
+ // Drop event only if BOTH text and attachments are empty
+ if text.trim().is_empty() && media_refs.is_empty() {
return empty_json_response();
}
- let sender = msg.sender.as_ref().or(chat.user.as_ref());
- let space = msg.space.as_ref().or(payload.space.as_ref());
+ let sender = msg.sender.as_ref().or(top_user.as_ref());
+ let space = msg.space.as_ref().or(top_space.as_ref());
let is_bot = sender.map(|s| s.user_type == "BOT").unwrap_or(false);
if is_bot {
@@ -473,28 +556,178 @@ pub async fn webhook(
.unwrap_or(&msg.name)
.to_string();
- let gw_event = GatewayEvent::new(
+ // No attachments → emit event synchronously and respond 200
+ if media_refs.is_empty() {
+ send_googlechat_event(
+ &state,
+ &space_name,
+ space_type,
+ thread_id,
+ &sender_id,
+ &sender_name,
+ &display_name,
+ text,
+ &message_id,
+ Vec::new(),
+ );
+ return empty_json_response();
+ }
+
+ // Has attachments — spawn background task so the webhook returns 200 within
+ // Google Chat's 30 s deadline regardless of how long downloads take.
+ let text = text.to_string();
+ let state = state.clone();
+ let spawn_space = space_name.clone();
+ tokio::spawn(async move {
+ use futures_util::FutureExt;
+ let result = std::panic::AssertUnwindSafe(async {
+ let mut downloaded: Vec = Vec::new();
+ let mut text_file_count: usize = 0;
+ let mut text_file_bytes: u64 = 0;
+ if let Some(ref adapter) = state.google_chat {
+ if let Some(token) = adapter.get_token().await {
+ for media_ref in &media_refs {
+ let attachment = match media_ref {
+ GoogleChatMediaRef::Image {
+ resource_name,
+ content_name,
+ ..
+ } => {
+ download_googlechat_image(
+ &adapter.client,
+ &token,
+ &adapter.api_base,
+ resource_name,
+ content_name,
+ )
+ .await
+ }
+ GoogleChatMediaRef::File {
+ resource_name,
+ content_name,
+ ..
+ } => {
+ if text_file_count >= TEXT_FILE_COUNT_CAP {
+ warn!(content_name = %content_name, cap = TEXT_FILE_COUNT_CAP, "googlechat text file count cap reached, skipping");
+ continue;
+ }
+ let remaining = TEXT_TOTAL_CAP.saturating_sub(text_file_bytes);
+ let att = download_googlechat_file(
+ &adapter.client,
+ &token,
+ &adapter.api_base,
+ resource_name,
+ content_name,
+ remaining,
+ )
+ .await;
+ let Some(att) = att else { continue };
+ text_file_count += 1;
+ text_file_bytes += att.size;
+ Some(att)
+ }
+ GoogleChatMediaRef::Audio {
+ resource_name,
+ content_name,
+ content_type,
+ } => {
+ download_googlechat_audio(
+ &adapter.client,
+ &token,
+ &adapter.api_base,
+ resource_name,
+ content_name,
+ content_type,
+ )
+ .await
+ }
+ };
+ if let Some(att) = attachment {
+ downloaded.push(att);
+ }
+ }
+ } else {
+ warn!("googlechat: no token available for attachment download");
+ }
+ }
+
+ // If text is empty AND every attachment failed to download, drop the event.
+ if text.trim().is_empty() && downloaded.is_empty() {
+ warn!(
+ space = %space_name,
+ "googlechat: empty text + all attachments failed, dropping event"
+ );
+ return;
+ }
+
+ send_googlechat_event(
+ &state,
+ &space_name,
+ space_type,
+ thread_id,
+ &sender_id,
+ &sender_name,
+ &display_name,
+ &text,
+ &message_id,
+ downloaded,
+ );
+ }).catch_unwind().await;
+ if let Err(e) = result {
+ error!(space = %spawn_space, "googlechat attachment download task panicked: {e:?}");
+ }
+ });
+
+ empty_json_response()
+}
+
+#[allow(clippy::too_many_arguments)]
+fn send_googlechat_event(
+ state: &Arc,
+ space_name: &str,
+ space_type: String,
+ thread_id: Option,
+ sender_id: &str,
+ sender_name: &str,
+ display_name: &str,
+ text: &str,
+ message_id: &str,
+ attachments: Vec,
+) {
+ let mut gw_event = GatewayEvent::new(
"googlechat",
ChannelInfo {
- id: space_name.clone(),
+ id: space_name.to_string(),
channel_type: space_type,
thread_id,
},
SenderInfo {
- id: sender_id,
- name: sender_name.clone(),
- display_name,
+ id: sender_id.to_string(),
+ name: sender_name.to_string(),
+ display_name: display_name.to_string(),
is_bot: false,
},
text,
- &message_id,
+ message_id,
vec![],
);
+ gw_event.content.attachments = attachments;
- let json = serde_json::to_string(&gw_event).unwrap();
- info!(space = %space_name, sender = %sender_name, "googlechat → gateway");
+ let attachment_count = gw_event.content.attachments.len();
+ let json = match serde_json::to_string(&gw_event) {
+ Ok(j) => j,
+ Err(e) => {
+ error!(error = %e, "googlechat: failed to serialize GatewayEvent");
+ return;
+ }
+ };
+ info!(
+ space = %space_name,
+ sender = %sender_name,
+ attachment_count,
+ "googlechat → gateway"
+ );
let _ = state.event_tx.send(json);
- empty_json_response()
}
fn empty_json_response() -> axum::response::Response {
@@ -903,6 +1136,254 @@ fn split_text(text: &str, limit: usize) -> Vec<&str> {
chunks
}
+// --- Attachment parsing & download ---
+
+/// Whitelist of text-like file extensions for `download_googlechat_file`.
+const TEXT_EXTS: &[&str] = &[
+ "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml",
+ "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp",
+ "rb", "sh", "bash", "sql", "html", "css", "ini", "cfg", "conf",
+];
+
+/// Parse Google Chat attachment array into media references for async download.
+///
+/// Skips Drive-sourced attachments (different download API), and unknown
+/// content types. Branches on `contentType` prefix to bucket into image /
+/// audio / file.
+fn parse_attachments(attachments: &[GoogleChatAttachment]) -> Vec {
+ let mut refs = Vec::new();
+ for att in attachments {
+ // Only handle UPLOADED_CONTENT (Drive needs separate Drive API call)
+ if att.source.as_deref() != Some("UPLOADED_CONTENT") {
+ continue;
+ }
+ let resource_name = match att
+ .attachment_data_ref
+ .as_ref()
+ .and_then(|d| d.resource_name.clone())
+ {
+ Some(rn) => rn,
+ None => continue,
+ };
+ let content_type = att.content_type.clone().unwrap_or_default();
+ let content_name = att.content_name.clone().unwrap_or_else(|| "file".into());
+
+ if content_type.starts_with("image/") {
+ refs.push(GoogleChatMediaRef::Image {
+ resource_name,
+ content_name,
+ });
+ } else if content_type.starts_with("audio/") {
+ refs.push(GoogleChatMediaRef::Audio {
+ resource_name,
+ content_name,
+ content_type,
+ });
+ } else if content_type.starts_with("video/") {
+ info!(content_name = %content_name, content_type = %content_type, "googlechat: video attachment skipped (not yet supported)");
+ } else {
+ refs.push(GoogleChatMediaRef::File {
+ resource_name,
+ content_name,
+ });
+ }
+ }
+ refs
+}
+
+/// Resize image so longest side ≤ 1200px, then encode as JPEG.
+/// GIFs are passed through unchanged to preserve animation.
+fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> {
+ use image::ImageReader;
+ use std::io::Cursor;
+
+ let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?;
+ let format = reader.format();
+ if format == Some(image::ImageFormat::Gif) {
+ return Ok((raw.to_vec(), "image/gif".to_string()));
+ }
+ let img = reader.decode()?;
+ let (w, h) = (img.width(), img.height());
+ let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX {
+ let max_side = std::cmp::max(w, h);
+ let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side);
+ let new_w = (f64::from(w) * ratio) as u32;
+ let new_h = (f64::from(h) * ratio) as u32;
+ img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3)
+ } else {
+ img
+ };
+ let mut buf = Cursor::new(Vec::new());
+ let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY);
+ img.write_with_encoder(encoder)?;
+ Ok((buf.into_inner(), "image/jpeg".to_string()))
+}
+
+/// Build the Media API URL for a given resource_name.
+/// Google Chat Media API uses `{+resourceName}` (RFC 6570 reserved expansion),
+/// so `/` must stay literal while other special chars are percent-encoded.
+fn media_url(api_base: &str, resource_name: &str) -> String {
+ let encoded: String = resource_name
+ .bytes()
+ .map(|b| match b {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
+ (b as char).to_string()
+ }
+ _ => format!("%{:02X}", b),
+ })
+ .collect();
+ format!("{}/media/{}?alt=media", api_base, encoded)
+}
+
+/// Download an image attachment via Google Chat Media API → resize/compress → base64.
+pub async fn download_googlechat_image(
+ client: &reqwest::Client,
+ token: &str,
+ api_base: &str,
+ resource_name: &str,
+ content_name: &str,
+) -> Option {
+ let url = media_url(api_base, resource_name);
+ let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ warn!(content_name, error = %e, "googlechat image download failed");
+ return None;
+ }
+ };
+ if !resp.status().is_success() {
+ warn!(content_name, status = %resp.status(), "googlechat image download failed");
+ return None;
+ }
+ if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) {
+ if let Ok(size) = cl.to_str().unwrap_or("0").parse::() {
+ if size > IMAGE_MAX_DOWNLOAD {
+ warn!(content_name, size, "googlechat image Content-Length exceeds 10MB limit");
+ return None;
+ }
+ }
+ }
+ let bytes = resp.bytes().await.ok()?;
+ if bytes.len() as u64 > IMAGE_MAX_DOWNLOAD {
+ warn!(content_name, size = bytes.len(), "googlechat image exceeds 10MB limit");
+ return None;
+ }
+ let (compressed, mime) = match resize_and_compress(&bytes) {
+ Ok(v) => v,
+ Err(e) => {
+ warn!(content_name, error = %e, "googlechat image resize failed");
+ return None;
+ }
+ };
+ let path = crate::store::store_media(&compressed).await?;
+ Some(crate::schema::Attachment {
+ attachment_type: "image".into(),
+ filename: content_name.to_string(),
+ mime_type: mime,
+ data: String::new(),
+ size: compressed.len() as u64,
+ path: Some(path),
+ })
+}
+
+/// Download a text-like file via Google Chat Media API → base64.
+/// Non-text extensions are skipped to avoid sending binary garbage to the model.
+pub async fn download_googlechat_file(
+ client: &reqwest::Client,
+ token: &str,
+ api_base: &str,
+ resource_name: &str,
+ content_name: &str,
+ remaining_budget: u64,
+) -> Option {
+ let ext = content_name.rsplit('.').next().unwrap_or("").to_lowercase();
+ if !TEXT_EXTS.contains(&ext.as_str()) {
+ tracing::debug!(content_name, "skipping non-text googlechat file attachment");
+ return None;
+ }
+ let max_size = FILE_MAX_DOWNLOAD.min(remaining_budget);
+ let url = media_url(api_base, resource_name);
+ let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ warn!(content_name, error = %e, "googlechat file download failed");
+ return None;
+ }
+ };
+ if !resp.status().is_success() {
+ warn!(content_name, status = %resp.status(), "googlechat file download failed");
+ return None;
+ }
+ if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) {
+ if let Ok(size) = cl.to_str().unwrap_or("0").parse::() {
+ if size > max_size {
+ warn!(content_name, size, limit = max_size, "googlechat file Content-Length exceeds limit");
+ return None;
+ }
+ }
+ }
+ let bytes = resp.bytes().await.ok()?;
+ if bytes.len() as u64 > max_size {
+ warn!(content_name, size = bytes.len(), limit = max_size, "googlechat file exceeds size limit");
+ return None;
+ }
+ let path = crate::store::store_media(&bytes).await?;
+ Some(crate::schema::Attachment {
+ attachment_type: "text_file".into(),
+ filename: content_name.to_string(),
+ mime_type: "text/plain".into(),
+ data: String::new(),
+ size: bytes.len() as u64,
+ path: Some(path),
+ })
+}
+
+/// Download an audio attachment as-is (no resize/transcode) → filesystem store.
+/// Core's STT pipeline (when available) consumes this as `audio` attachment_type.
+pub async fn download_googlechat_audio(
+ client: &reqwest::Client,
+ token: &str,
+ api_base: &str,
+ resource_name: &str,
+ content_name: &str,
+ content_type: &str,
+) -> Option {
+ let url = media_url(api_base, resource_name);
+ let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ warn!(content_name, error = %e, "googlechat audio download failed");
+ return None;
+ }
+ };
+ if !resp.status().is_success() {
+ warn!(content_name, status = %resp.status(), "googlechat audio download failed");
+ return None;
+ }
+ if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) {
+ if let Ok(size) = cl.to_str().unwrap_or("0").parse::() {
+ if size > AUDIO_MAX_DOWNLOAD {
+ warn!(content_name, size, "googlechat audio Content-Length exceeds 25MB limit");
+ return None;
+ }
+ }
+ }
+ let bytes = resp.bytes().await.ok()?;
+ if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD {
+ warn!(content_name, size = bytes.len(), "googlechat audio exceeds 25MB limit");
+ return None;
+ }
+ let path = crate::store::store_media(&bytes).await?;
+ Some(crate::schema::Attachment {
+ attachment_type: "audio".into(),
+ filename: content_name.to_string(),
+ mime_type: content_type.to_string(),
+ data: String::new(),
+ size: bytes.len() as u64,
+ path: Some(path),
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1178,8 +1659,8 @@ mod tests {
}
#[test]
- fn email_claim_accepts_gsuite_addons_account() {
- let claims = serde_json::json!({"email": "service-123456@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"});
+ fn email_claim_accepts_chat_system_account() {
+ let claims = serde_json::json!({"email": "chat@system.gserviceaccount.com"});
assert!(verify_email_claim(&claims).is_ok());
}
@@ -1370,10 +1851,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: "hello".into(),
},
command: None,
request_id: Some("req_123".into()),
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1412,10 +1895,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: "hello".into(),
},
command: None,
request_id: Some("req_fail".into()),
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1458,10 +1943,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: "".into(),
},
command: None,
request_id: Some("req_empty".into()),
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1501,10 +1988,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: long_text,
},
command: None,
request_id: Some("req_multi_fail".into()),
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1534,10 +2023,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: "hello".into(),
},
command: None,
request_id: Some("req_notoken".into()),
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1578,10 +2069,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: "updated text".into(),
},
command: Some("edit_message".into()),
request_id: None,
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1619,10 +2112,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: long_text,
},
command: None,
request_id: Some("req_multi".into()),
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1675,10 +2170,12 @@ mod tests {
},
content: Content {
content_type: "text".into(),
+ attachments: Vec::new(),
text: long_text,
},
command: None,
request_id: Some("req_partial".into()),
+ quote_message_id: None,
};
adapter.handle_reply(&reply, &event_tx).await;
@@ -1692,4 +2189,282 @@ mod tests {
let err = resp.error.expect("partial failure should set error");
assert!(err.contains("500"));
}
+
+ // --- Attachment parsing tests ---
+
+ fn make_attachment(
+ source: &str,
+ content_type: &str,
+ content_name: &str,
+ resource_name: Option<&str>,
+ ) -> GoogleChatAttachment {
+ GoogleChatAttachment {
+ name: Some("spaces/SP/messages/MSG/attachments/ATT".into()),
+ content_name: Some(content_name.into()),
+ content_type: Some(content_type.into()),
+ source: Some(source.into()),
+ attachment_data_ref: resource_name.map(|rn| AttachmentDataRef {
+ resource_name: Some(rn.into()),
+ }),
+ drive_data_ref: None,
+ }
+ }
+
+ #[test]
+ fn parse_attachments_image() {
+ let atts = vec![make_attachment(
+ "UPLOADED_CONTENT",
+ "image/png",
+ "photo.png",
+ Some("AATT_resource"),
+ )];
+ let refs = parse_attachments(&atts);
+ assert_eq!(refs.len(), 1);
+ match &refs[0] {
+ GoogleChatMediaRef::Image {
+ resource_name,
+ content_name,
+ } => {
+ assert_eq!(resource_name, "AATT_resource");
+ assert_eq!(content_name, "photo.png");
+ }
+ other => panic!("expected Image, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn parse_attachments_audio() {
+ let atts = vec![make_attachment(
+ "UPLOADED_CONTENT",
+ "audio/mp4",
+ "voice.m4a",
+ Some("AATT"),
+ )];
+ let refs = parse_attachments(&atts);
+ assert!(matches!(refs[0], GoogleChatMediaRef::Audio { .. }));
+ }
+
+ #[test]
+ fn parse_attachments_file() {
+ let atts = vec![make_attachment(
+ "UPLOADED_CONTENT",
+ "text/plain",
+ "notes.txt",
+ Some("AATT"),
+ )];
+ let refs = parse_attachments(&atts);
+ assert!(matches!(refs[0], GoogleChatMediaRef::File { .. }));
+ }
+
+ #[test]
+ fn parse_attachments_skips_drive() {
+ let atts = vec![GoogleChatAttachment {
+ name: Some("spaces/SP/messages/MSG/attachments/ATT".into()),
+ content_name: Some("doc".into()),
+ content_type: Some("application/vnd.google-apps.document".into()),
+ source: Some("DRIVE_FILE".into()),
+ attachment_data_ref: None,
+ drive_data_ref: Some(DriveDataRef {
+ drive_file_id: Some("drive_id_123".into()),
+ }),
+ }];
+ assert_eq!(parse_attachments(&atts).len(), 0);
+ }
+
+ #[test]
+ fn parse_attachments_skips_missing_resource_name() {
+ let atts = vec![make_attachment(
+ "UPLOADED_CONTENT",
+ "image/png",
+ "photo.png",
+ None,
+ )];
+ assert_eq!(parse_attachments(&atts).len(), 0);
+ }
+
+ #[test]
+ fn media_url_preserves_slashes_and_encodes_specials() {
+ let url = media_url("https://chat.googleapis.com/v1", "spaces/SP/messages/MSG/attachments/ATT");
+ assert_eq!(
+ url,
+ "https://chat.googleapis.com/v1/media/spaces/SP/messages/MSG/attachments/ATT?alt=media"
+ );
+ let url2 = media_url("https://chat.googleapis.com/v1", "AATT/some+resource=name");
+ assert_eq!(
+ url2,
+ "https://chat.googleapis.com/v1/media/AATT/some%2Bresource%3Dname?alt=media"
+ );
+ }
+
+ #[tokio::test]
+ async fn download_googlechat_image_resizes_and_returns_attachment() {
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+ use wiremock::matchers::{method, path_regex};
+
+ // Generate a small valid PNG
+ let img = image::RgbImage::from_pixel(10, 10, image::Rgb([255, 0, 0]));
+ let mut buf = std::io::Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgb8(img)
+ .write_to(&mut buf, image::ImageFormat::Png)
+ .unwrap();
+ let png_bytes = buf.into_inner();
+
+ let mock_server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path_regex("/media/.*"))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .set_body_bytes(png_bytes)
+ .insert_header("content-type", "image/png"),
+ )
+ .mount(&mock_server)
+ .await;
+
+ let client = reqwest::Client::new();
+ let result = download_googlechat_image(
+ &client,
+ "fake-token",
+ &mock_server.uri(),
+ "AATT_resource",
+ "photo.png",
+ )
+ .await;
+ let att = result.expect("expected successful download");
+ assert_eq!(att.attachment_type, "image");
+ assert_eq!(att.filename, "photo.png");
+ assert_eq!(att.mime_type, "image/jpeg"); // resized PNG → JPEG
+ assert!(att.path.is_some()); // stored to filesystem
+ assert!(att.size > 0);
+ }
+
+ #[tokio::test]
+ async fn download_googlechat_file_rejects_non_text_extension() {
+ let client = reqwest::Client::new();
+ let result = download_googlechat_file(
+ &client,
+ "fake-token",
+ "https://unused", // not called for non-text
+ "AATT",
+ "binary.exe",
+ TEXT_TOTAL_CAP,
+ )
+ .await;
+ assert!(result.is_none(), "non-text extensions must be skipped");
+ }
+
+ #[tokio::test]
+ async fn download_googlechat_file_text_extension_succeeds() {
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+ use wiremock::matchers::{method, path_regex};
+
+ let mock_server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path_regex("/media/.*"))
+ .respond_with(
+ ResponseTemplate::new(200).set_body_bytes(b"hello world".to_vec()),
+ )
+ .mount(&mock_server)
+ .await;
+
+ let client = reqwest::Client::new();
+ let result = download_googlechat_file(
+ &client,
+ "fake-token",
+ &mock_server.uri(),
+ "AATT",
+ "notes.txt",
+ TEXT_TOTAL_CAP,
+ )
+ .await;
+ let att = result.expect("expected successful download");
+ assert_eq!(att.attachment_type, "text_file");
+ assert_eq!(att.filename, "notes.txt");
+ assert_eq!(att.mime_type, "text/plain");
+ }
+
+ #[tokio::test]
+ async fn download_googlechat_audio_returns_attachment() {
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+ use wiremock::matchers::{method, path_regex};
+
+ let mock_server = MockServer::start().await;
+ let audio_bytes = vec![0u8; 1024];
+ Mock::given(method("GET"))
+ .and(path_regex("/media/.*"))
+ .respond_with(ResponseTemplate::new(200).set_body_bytes(audio_bytes.clone()))
+ .mount(&mock_server)
+ .await;
+
+ let client = reqwest::Client::new();
+ let result = download_googlechat_audio(
+ &client,
+ "fake-token",
+ &mock_server.uri(),
+ "AATT",
+ "voice.m4a",
+ "audio/mp4",
+ )
+ .await;
+ let att = result.expect("expected successful download");
+ assert_eq!(att.attachment_type, "audio");
+ assert_eq!(att.filename, "voice.m4a");
+ assert_eq!(att.mime_type, "audio/mp4");
+ assert_eq!(att.size, 1024);
+ }
+
+ #[tokio::test]
+ async fn download_googlechat_image_rejects_oversized_content_length() {
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+ use wiremock::matchers::{method, path_regex};
+
+ let mock_server = MockServer::start().await;
+ Mock::given(method("GET"))
+ .and(path_regex("/media/.*"))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .insert_header("content-length", "20000000") // 20 MB > 10 MB limit
+ .set_body_bytes(vec![0u8; 100]),
+ )
+ .mount(&mock_server)
+ .await;
+
+ let client = reqwest::Client::new();
+ let result = download_googlechat_image(
+ &client,
+ "fake-token",
+ &mock_server.uri(),
+ "AATT",
+ "huge.png",
+ )
+ .await;
+ assert!(result.is_none(), "oversized image must be rejected");
+ }
+
+ #[test]
+ fn parses_http_endpoint_url_top_level_envelope() {
+ let envelope: GoogleChatEnvelope = serde_json::from_value(serde_json::json!({
+ "message": {
+ "name": "spaces/AAAA/messages/BBBB",
+ "text": "hello",
+ "attachment": []
+ },
+ "user": {
+ "name": "users/123",
+ "displayName": "Test User",
+ "type": "HUMAN"
+ },
+ "space": {
+ "name": "spaces/AAAA",
+ "type": "DM"
+ }
+ }))
+ .unwrap();
+ assert!(envelope.chat.is_none());
+ assert!(envelope.message.is_some());
+ assert_eq!(envelope.message.unwrap().name, "spaces/AAAA/messages/BBBB");
+ assert!(envelope.user.is_some());
+ assert_eq!(envelope.user.unwrap().name, "users/123");
+ assert!(envelope.space.is_some());
+ assert_eq!(envelope.space.unwrap().name, "spaces/AAAA");
+ }
}
diff --git a/gateway/src/adapters/mod.rs b/gateway/src/adapters/mod.rs
index f261efe68..94a2a8a79 100644
--- a/gateway/src/adapters/mod.rs
+++ b/gateway/src/adapters/mod.rs
@@ -3,3 +3,4 @@ pub mod googlechat;
pub mod line;
pub mod teams;
pub mod telegram;
+pub mod wecom;
diff --git a/gateway/src/adapters/wecom.rs b/gateway/src/adapters/wecom.rs
new file mode 100644
index 000000000..e3e97ff17
--- /dev/null
+++ b/gateway/src/adapters/wecom.rs
@@ -0,0 +1,1654 @@
+use anyhow::Result;
+use axum::extract::State;
+use std::sync::Arc;
+use tokio::sync::RwLock;
+use tracing::{info, warn};
+
+pub struct WecomConfig {
+ pub corp_id: String,
+ pub agent_id: String,
+ pub secret: String,
+ pub token: String,
+ pub encoding_aes_key: String,
+ pub webhook_path: String,
+ pub streaming_enabled: bool,
+ pub debounce_secs: u64,
+}
+
+impl WecomConfig {
+ pub fn from_env() -> Option {
+ Self::from_reader(|k| std::env::var(k).ok())
+ }
+
+ /// Build config from an arbitrary string reader. Tests use this with a
+ /// HashMap so they don't mutate process-wide environment variables —
+ /// `env::set_var` races other tests under cargo's parallel runner.
+ fn from_reader Option>(read: F) -> Option {
+ let corp_id = read("WECOM_CORP_ID")?;
+ let secret = read("WECOM_SECRET")?;
+ let token = read("WECOM_TOKEN")?;
+ let encoding_aes_key = read("WECOM_ENCODING_AES_KEY")?;
+ let agent_id = read("WECOM_AGENT_ID")?;
+ if agent_id.parse::().is_err() {
+ warn!("WECOM_AGENT_ID must be a numeric value, got '{}'", agent_id);
+ return None;
+ }
+ let webhook_path = read("WECOM_WEBHOOK_PATH").unwrap_or_else(|| "/webhook/wecom".into());
+ // Streaming opts-in: WeCom callback mode has no edit-message API, so
+ // streaming is implemented via thinking-placeholder + recall + resend,
+ // which causes a brief client flicker. Default off; set to true only if
+ // the UX tradeoff is acceptable.
+ let streaming_enabled = read("WECOM_STREAMING_ENABLED")
+ .map(|v| v == "true" || v == "1")
+ .unwrap_or(false);
+ let debounce_secs = read("WECOM_DEBOUNCE_SECS")
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(3);
+
+ if encoding_aes_key.len() != 43 {
+ warn!("WECOM_ENCODING_AES_KEY must be 43 characters, got {}", encoding_aes_key.len());
+ return None;
+ }
+
+ info!(
+ corp_id = %corp_id,
+ agent_id = %agent_id,
+ streaming_enabled,
+ debounce_secs,
+ "wecom adapter configured"
+ );
+ Some(Self {
+ corp_id,
+ agent_id,
+ secret,
+ token,
+ encoding_aes_key,
+ webhook_path,
+ streaming_enabled,
+ debounce_secs,
+ })
+ }
+}
+
+fn decode_aes_key(encoding_aes_key: &str) -> anyhow::Result> {
+ use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
+ use base64::Engine;
+ // WeCom's EncodingAESKey is 43 base64 chars without trailing padding.
+ // Append "=" to make it a 44-char standard base64 string before decoding.
+ // Indifferent + allow_trailing_bits accommodate WeCom's non-standard
+ // encoding: the 43rd char's last 2 bits are not part of the output and
+ // must be ignored rather than rejected.
+ let padded = format!("{}=", encoding_aes_key);
+ let config = GeneralPurposeConfig::new()
+ .with_decode_padding_mode(DecodePaddingMode::Indifferent)
+ .with_decode_allow_trailing_bits(true);
+ let engine = GeneralPurpose::new(&base64::alphabet::STANDARD, config);
+ let key = engine
+ .decode(&padded)
+ .map_err(|e| anyhow::anyhow!("encoding_aes_key base64 decode failed: {e}"))?;
+ anyhow::ensure!(
+ key.len() == 32,
+ "encoding_aes_key must decode to 32 bytes, got {}",
+ key.len()
+ );
+ Ok(key)
+}
+
+fn compute_signature(token: &str, timestamp: &str, nonce: &str, encrypt: &str) -> String {
+ use sha1::Digest;
+ let mut parts = [token, timestamp, nonce, encrypt];
+ parts.sort_unstable();
+ let joined: String = parts.concat();
+ let hash = sha1::Sha1::digest(joined.as_bytes());
+ format!("{:x}", hash)
+}
+
+fn verify_signature(
+ token: &str,
+ timestamp: &str,
+ nonce: &str,
+ encrypt: &str,
+ expected: &str,
+) -> bool {
+ let computed = compute_signature(token, timestamp, nonce, encrypt);
+ tracing::debug!(
+ computed = %computed,
+ expected = %expected,
+ token_len = token.len(),
+ encrypt_len = encrypt.len(),
+ "signature comparison"
+ );
+ subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), expected.as_bytes()).into()
+}
+
+fn decrypt_message(
+ encoding_aes_key: &str,
+ encrypted: &str,
+ expected_corp_id: &str,
+) -> anyhow::Result {
+ use aes::cipher::{BlockDecryptMut, KeyIvInit};
+ use base64::Engine;
+
+ let key = decode_aes_key(encoding_aes_key)?;
+ let iv = &key[..16];
+
+ let cipher_bytes = base64::engine::general_purpose::STANDARD
+ .decode(encrypted)
+ .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?;
+
+ if cipher_bytes.is_empty() || cipher_bytes.len() % 16 != 0 {
+ anyhow::bail!("ciphertext length {} not a multiple of 16", cipher_bytes.len());
+ }
+
+ type Aes256CbcDec = cbc::Decryptor;
+ let decryptor = Aes256CbcDec::new_from_slices(&key, iv)
+ .map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?;
+
+ let mut buf = cipher_bytes.to_vec();
+ // WeCom uses PKCS7 with block_size=32, not 16. Decrypt without padding validation
+ // and strip padding manually.
+ let plaintext = decryptor
+ .decrypt_padded_mut::(&mut buf)
+ .map_err(|e| anyhow::anyhow!("aes decrypt failed: {e}"))?;
+
+ // Strip WeCom PKCS7 padding (block_size=32): last byte indicates pad length (1-32)
+ let pad_byte = *plaintext.last().ok_or_else(|| anyhow::anyhow!("empty plaintext"))? as usize;
+ if pad_byte == 0 || pad_byte > 32 || pad_byte > plaintext.len() {
+ anyhow::bail!("invalid wecom padding value: {pad_byte}");
+ }
+ let pad_start = plaintext.len() - pad_byte;
+ if !plaintext[pad_start..].iter().all(|&b| b as usize == pad_byte) {
+ anyhow::bail!("invalid PKCS#7 padding: not all padding bytes match");
+ }
+ let plaintext = &plaintext[..pad_start];
+
+ // Plaintext structure: random(16) + msg_len(4, big-endian) + msg + corp_id
+ if plaintext.len() < 20 {
+ anyhow::bail!("decrypted payload too short");
+ }
+ let msg_len =
+ u32::from_be_bytes([plaintext[16], plaintext[17], plaintext[18], plaintext[19]]) as usize;
+ if plaintext.len() < 20 + msg_len {
+ anyhow::bail!("msg_len exceeds payload size");
+ }
+ let msg = &plaintext[20..20 + msg_len];
+ let corp_id = &plaintext[20 + msg_len..];
+
+ let corp_id_str =
+ std::str::from_utf8(corp_id).map_err(|e| anyhow::anyhow!("corp_id not utf8: {e}"))?;
+ if corp_id_str != expected_corp_id {
+ anyhow::bail!("corp_id mismatch: expected {expected_corp_id}, got {corp_id_str}");
+ }
+
+ String::from_utf8(msg.to_vec()).map_err(|e| anyhow::anyhow!("message not utf8: {e}"))
+}
+
+// --- Deduplication ---
+
+const DEDUPE_TTL_SECS: u64 = 30;
+const DEDUPE_MAX_SIZE: usize = 10_000;
+
+struct DedupeCache {
+ entries: std::sync::Mutex>,
+}
+
+impl DedupeCache {
+ fn new() -> Self {
+ Self {
+ entries: std::sync::Mutex::new(std::collections::HashMap::new()),
+ }
+ }
+
+ fn check_and_insert(&self, msg_id: &str) -> bool {
+ let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
+ let now = std::time::Instant::now();
+
+ if entries.len() >= DEDUPE_MAX_SIZE {
+ entries.retain(|_, t| now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS);
+ }
+
+ if let Some(t) = entries.get(msg_id) {
+ if now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS {
+ return false;
+ }
+ }
+
+ entries.insert(msg_id.to_string(), now);
+ true
+ }
+}
+
+// --- Token cache ---
+
+pub const WECOM_API_BASE: &str = "https://qyapi.weixin.qq.com";
+const TOKEN_REFRESH_MARGIN_SECS: u64 = 300;
+
+pub struct WecomTokenCache {
+ inner: RwLock