Merge branch 'feat/ai-credit-system' of https://github.com/TheUntraceable/autumn into feat/ai-credit-system

This commit is contained in:
Ridhwan Hussain
2026-06-10 15:10:41 +01:00
1264 changed files with 161428 additions and 13834 deletions

6
.bt/config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"profile": null,
"org": "autumn",
"project": "leaf",
"project_id": "b5592c45-a906-4b43-93b7-bd05a8172b0b"
}

View File

@@ -14,16 +14,24 @@ on:
required: false
default: 'manual'
# Cancel an older in-progress build on the same branch when a newer commit
# lands, so the latest commit is the one that builds + deploys.
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
env:
AWS_REGION: us-west-2
# ECR region is selected per-branch in the "Extract metadata" step:
# prod repo (autumn) -> us-east-2
# staging repo (autumn-staging) -> us-east-1
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
# Add short-lived PR branches here when you need staging without merging to dev.
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset
jobs:
checks:
name: Type Check
runs-on: ubuntu-latest
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout code
@@ -55,17 +63,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract metadata for Docker
id: meta
env:
@@ -103,11 +100,13 @@ jobs:
echo "sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
echo "deploy_staging_override=$DEPLOY_STAGING_OVERRIDE" >> $GITHUB_OUTPUT
# Select ECR repository based on branch
# Select ECR repository + region based on branch
if [ "$BRANCH_NAME" = "dev" ] || [ "$DEPLOY_STAGING_OVERRIDE" = "true" ]; then
echo "ecr_repo=autumn-staging" >> $GITHUB_OUTPUT
echo "region=us-east-1" >> $GITHUB_OUTPUT
else
echo "ecr_repo=autumn" >> $GITHUB_OUTPUT
echo "region=us-east-2" >> $GITHUB_OUTPUT
fi
# Set custom tag if provided
@@ -115,8 +114,19 @@ jobs:
echo "custom_tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ steps.meta.outputs.region }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Blacksmith Docker builder
uses: useblacksmith/setup-docker-builder@v1
- name: Build and push Docker image
env:
@@ -129,14 +139,13 @@ jobs:
COMBINED_TAG="${IMAGE_TAG_BRANCH}-${IMAGE_TAG_SHA}"
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${COMBINED_TAG}"
# Build and push
# Layer + cache-mount persistence is handled by the Blacksmith sticky
# disk mounted at /var/lib/buildkit, so no registry cache-from/to.
docker buildx build \
--platform linux/amd64 \
--push \
--provenance=false \
--sbom=false \
--cache-from type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH} \
--cache-to type=registry,ref=${ECR_REGISTRY}/${ECR_REPOSITORY}:buildcache-${IMAGE_TAG_BRANCH},mode=max \
--tag ${TAGS//,/ --tag } \
-f docker/Dockerfile \
.
@@ -153,31 +162,19 @@ jobs:
echo "Repository: ${ECR_REGISTRY}/${ECR_REPOSITORY}"
echo "Tag: ${COMBINED_TAG}"
- name: Auto-deploy fix branches
- name: Auto-deploy production
if: github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPLOY_URL: ${{ secrets.DEPLOY_URL }}
DEPLOY_SECRET: ${{ secrets.DEPLOY_SECRET }}
COMMIT_SHA: ${{ github.sha }}
run: |
# Get the PR that was merged (if any)
PR_DATA=$(gh pr list --state merged --search "$COMMIT_SHA" --json headRefName --limit 1)
BRANCH_NAME=$(echo "$PR_DATA" | jq -r '.[0].headRefName // empty')
if [[ "$BRANCH_NAME" == fix/* ]]; then
echo "Detected merged fix/ branch: $BRANCH_NAME"
echo "Triggering auto-deploy..."
curl --fail-with-body -X POST "$DEPLOY_URL/api/deploy/github" \
-H "Content-Type: application/json" \
-H "x-deploy-secret: $DEPLOY_SECRET" \
-d "{\"commitSha\": \"$COMMIT_SHA\", \"deploymentType\": \"server\"}"
echo "Auto-deploy triggered!"
else
echo "Not a fix/ branch (branch: ${BRANCH_NAME:-direct push}), skipping auto-deploy"
fi
echo "Triggering production auto-deploy for $COMMIT_SHA..."
curl --fail-with-body -X POST "$DEPLOY_URL/api/deploy/github" \
-H "Content-Type: application/json" \
-H "x-deploy-secret: $DEPLOY_SECRET" \
-d "{\"commitSha\": \"$COMMIT_SHA\"}"
echo "Auto-deploy triggered!"
- name: Auto-deploy staging
if: github.ref == 'refs/heads/dev' || steps.meta.outputs.deploy_staging_override == 'true'

View File

@@ -10,7 +10,7 @@ on:
jobs:
knip:
name: Check for unused code
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code

View File

@@ -46,7 +46,7 @@ env:
jobs:
publish:
name: Build and Publish
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
id-token: write

View File

@@ -10,7 +10,7 @@ permissions:
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
@@ -30,7 +30,7 @@ jobs:
name: Type Check
needs: changes
if: needs.changes.outputs.server == 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code

View File

@@ -2,8 +2,6 @@ name: Server Unit Tests
on:
pull_request:
paths:
- "server/**"
permissions:
contents: read
@@ -12,7 +10,7 @@ permissions:
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
server: ${{ steps.filter.outputs.server }}
steps:
@@ -32,7 +30,7 @@ jobs:
name: Unit Tests
needs: changes
if: needs.changes.outputs.server == 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code
@@ -41,11 +39,11 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
bun-version: 1.3.14
- name: Install dependencies
run: bun install
- name: Run unit tests
run: bun test tests/unit
run: bun test --isolate tests/unit
working-directory: server

View File

@@ -11,7 +11,7 @@ on:
jobs:
changes:
name: Detect schema-relevant changes
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
schema: ${{ steps.filter.outputs.schema }}
steps:
@@ -32,7 +32,7 @@ jobs:
validate-schema:
name: Validate Schema
needs: changes
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Skip (no schema-relevant changes)

View File

@@ -10,7 +10,7 @@ permissions:
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
vite: ${{ steps.filter.outputs.vite }}
steps:
@@ -28,11 +28,11 @@ jobs:
- "bun.lock"
- ".github/workflows/vite-build.yml"
typecheck:
name: Type Check
build:
name: Build & Type Check
needs: changes
if: needs.changes.outputs.vite == 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout code
@@ -49,23 +49,5 @@ jobs:
- name: Run TypeScript type check
run: cd vite && bunx tsc --noEmit
build:
name: Build
needs: changes
if: needs.changes.outputs.vite == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- name: Install dependencies
run: bun install
- name: Build Vite
run: cd vite && bunx vite build

View File

@@ -2,8 +2,6 @@ name: Vite Unit Tests
on:
pull_request:
paths:
- "vite/**"
permissions:
contents: read
@@ -12,7 +10,7 @@ permissions:
jobs:
changes:
name: Check changed files
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
vite: ${{ steps.filter.outputs.vite }}
steps:
@@ -34,7 +32,7 @@ jobs:
name: Unit Tests
needs: changes
if: needs.changes.outputs.vite == 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code

3
.gitignore vendored
View File

@@ -21,6 +21,9 @@ supabase.sh
**/.env*
tests/
!server/tests
!packages/mcp/tests
!packages/ai-sdk/tests
!apps/leaf/tests
!vite/tests
.secrets

View File

@@ -0,0 +1,176 @@
# Plan: Stripe Subscription Item Consolidation
## Goal
Reduce noisy duplicate Stripe subscription items when Autumn attaches multiple customer products with identical billing semantics.
Today, multi-attach prepaid add-ons can create one Stripe price and one Stripe subscription item per customer product. In Stripe this looks like four separate lines even when the customer really bought two identical monthly packs and two identical annual packs:
- prepaid credits `$10 / month`
- prepaid credits `$10 / month`
- prepaid workflows `$10 / year`
- prepaid workflows `$10 / year`
The target shape is fewer Stripe subscription items with quantities or shared price resources, while Autumn still tracks each customer product, customer price, entitlement, cancellation, and renewal independently.
## Current Problem
Autumn often treats each attached customer product as its own concrete billing object. That is useful internally, but it leaks into Stripe:
- duplicate prepaid add-ons produce separate Stripe subscription items
- customized or customer-specific prices produce separate Stripe price IDs even when the price shape is identical
- schedules become harder to reason about because future phases contain repeated inline prices
- Stripe dashboard pricing tables become noisy and hard to audit
This also interacts with schedule updates. If each duplicate has its own subscription item, cancellation or phase replacement must preserve item identity carefully to avoid Stripe resetting item periods or creating unexpected prorations.
## Proposed Model
Introduce a Stripe-side aggregation layer between Autumn customer prices and Stripe subscription item specs.
Autumn should still create and store one `customer_product` and one or more `customer_prices` per attached product. Stripe does not need to receive one subscription item for every `customer_price` when multiple prices are billing-equivalent.
Group billable specs by a strict `StripeSubscriptionItemAggregationKey`:
- Stripe product identity
- currency
- recurring interval and interval count
- unit amount decimal or tier shape
- billing scheme, tiers mode, transform quantity, usage type
- tax behavior and tax code if present
- discount eligibility or attached discounts
- collection behavior relevant to invoice calculation
- any metadata that must appear on the Stripe line item
For each group:
- create one Stripe subscription item
- set quantity to the total billable quantity for the group
- persist an Autumn allocation map that records which customer prices are represented by that Stripe item
## Allocation Map
Collapsing requires an explicit mapping because Stripe item metadata cannot safely represent many customer prices forever.
Candidate storage options:
- add a table mapping `stripe_subscription_item_id -> customer_price_id`
- extend an existing customer price billing reference table if one exists
- store only current mappings in Autumn DB, not in Stripe metadata
The map should support:
- cancel one attached copy by decrementing Stripe quantity
- cancel all attached copies by deleting the Stripe item
- distinguish two customer products that share the same underlying `autumn_price_id`
- rebuild the mapping during sync or migration
- explain an invoice line item back to all represented customer prices
Stripe metadata can keep summary fields for debugging, but should not be the source of truth for many-to-one identity.
## Zero-Dollar Items
Do not create Stripe subscription items for `$0` recurring prices when another subscription item with the same billing interval can safely carry the period.
This should be conservative:
- omit only if the zero-dollar item has no Stripe-visible invoice effect
- omit only if Autumn can derive its period from another same-interval item
- keep the item if it is the only Stripe object establishing that interval
- keep the item if Stripe item identity is needed for schedule transitions, tax, discounts, trials, or external reporting
Zero-dollar omission should be a separate branch from paid-item consolidation. It is related, but riskier because the Stripe item disappears entirely.
## Non-Goals
Do not collapse items that merely look similar in the dashboard. They must be identical for Stripe invoice math.
Do not collapse across different intervals, currencies, Stripe products, tax behavior, discount behavior, or tier shapes.
Do not use `autumn_price_id` alone as the aggregation key. Two customer products can share the same Autumn price but still require separate Autumn lifecycle tracking.
Do not rely on a JSON blob in Stripe metadata as the durable allocation source. Metadata limits and sync behavior make that brittle.
## Implementation Shape
Add a dedicated aggregation step before converting Autumn billing specs into Stripe subscription items.
Likely areas:
- `server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/`
- `server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/`
- `server/src/internal/billing/v2/providers/stripe/utils/matchUtils/`
Candidate helpers:
```ts
buildStripeSubscriptionItemAggregationKey(spec)
collapseStripeItemSpecs(specs)
expandStripeInvoiceLineAllocations(lineItem)
```
The aggregation key should reuse the same price-shape comparison utilities we use for inline price matching. If the shape comparison says two inline prices are not identical, aggregation must not collapse them.
## Migration Strategy
Start with new subscriptions only.
For existing subscriptions:
1. Detect groups of active Stripe subscription items that are aggregation-equivalent.
2. Preview the Stripe update that would replace them with one item and a higher quantity.
3. Apply only if the preview has no invoice impact.
4. Persist the allocation map.
5. Leave ambiguous subscriptions untouched.
No migration should merge items if it would create prorations, change periods, or lose invoice-line attribution.
## Schedule Behavior
Schedule phases should collapse independently. A Stripe item can represent many Autumn customer prices within the same phase, but future phases must not accidentally reuse the same allocation map if membership changes.
Examples:
- Phase 1 has two identical monthly prepaid credit packs: one Stripe item, quantity `2`.
- Phase 2 cancels one pack: same Stripe item, quantity `1`.
- Phase 3 adds another identical pack: same billing key, quantity `2`, allocation map updated for that phase.
For inline prices, reuse the existing Stripe subscription item only when the collapsed group still matches the current item shape and period semantics.
## Test Matrix
Add tests before implementing:
- two identical monthly prepaid add-ons collapse to one Stripe item with quantity `2`
- two identical annual prepaid add-ons collapse to one Stripe item with quantity `2`
- cancel one of two identical monthly add-ons decrements quantity and invoices one renewal
- cancel one of two identical annual add-ons decrements quantity without charging annual renewal
- monthly and annual identical-looking add-ons do not collapse together
- same interval but different amount does not collapse
- same amount but different feature or Stripe product does not collapse unless explicitly allowed by product identity
- customized inline prices collapse only when exact Stripe price shape matches
- entity-scoped add-ons preserve allocation and entitlement ownership
- invoice line item matching can explain a collapsed Stripe line back to all represented customer prices
- checkout sessions either use the same aggregation rule or explicitly opt out
- subscription schedules preserve correct quantities across phase changes
- zero-dollar recurring item is omitted only when a same-interval carrier item exists
- zero-dollar-only interval keeps a Stripe item or an explicit Autumn period source
## Acceptance Criteria
- Stripe dashboard shows one line per billing-equivalent group, not one line per Autumn customer product.
- Autumn can still cancel, renew, migrate, and explain each customer product independently.
- Invoice totals are unchanged compared with the uncollapsed representation.
- Schedule phase transitions do not create extra prorations from aggregation changes alone.
- Sync and restore can rebuild or validate the allocation map.
- Existing multi-attach, checkout, invoice-line-item, schedule, and migration test groups pass.
## Risks
The main risk is losing one-to-one identity. Stripe has one subscription item, but Autumn may have many customer prices behind it. Every path that currently assumes `customer_price -> stripe_subscription_item` is one-to-one must be audited.
Changing quantity can have different Stripe proration behavior than deleting one item and keeping another. This must be covered with invoice previews and test clocks.
Zero-dollar omission can break period derivation if Autumn currently depends on a Stripe subscription item to know renewal timing. That needs a clear replacement source before removing those items.
This is a cleanup and correctness project, not just a dashboard polish. The implementation should be gated behind tests and probably a feature flag.

6
.vscode/tasks.json vendored
View File

@@ -4,21 +4,21 @@
{
"label": "Run Test Pattern",
"type": "shell",
"command": "infisical run --env=dev --recursive -- bun test ${relativeFile} -t \"${input:testPattern}\"",
"command": "./run.sh \"${file}\" -t \"${input:testPattern}\"",
"options": { "env": { "NODE_ENV": "development" } },
"problemMatcher": []
},
{
"label": "Run Describe at Cursor",
"type": "shell",
"command": "infisical run --env=dev --recursive -- bun test ${relativeFile} --timeout 0 -t \"$(bun scripts/testScripts/getDescribeAtCursor.ts ${file} ${lineNumber})\"",
"command": "./run.sh \"${file}\" ${lineNumber}",
"options": { "env": { "NODE_ENV": "development" } },
"problemMatcher": []
},
{
"label": "Run Current Test File",
"type": "shell",
"command": "infisical run --env=dev --recursive -- bun test ${relativeFile}",
"command": "./run.sh \"${file}\"",
"options": { "env": { "NODE_ENV": "development" } },
"problemMatcher": []
}

2
ai

Submodule ai updated: 0d561b1747...bca809a307

View File

@@ -413,6 +413,14 @@ This is useful for attaching custom metadata to the Stripe subscription created
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
</DynamicParamField>
<DynamicParamField body="invoice_template_id" type="string">
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
</DynamicParamField>
<DynamicParamField body="net_terms_days" type="integer">
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -380,6 +380,14 @@ const response = await autumn.billing.update({
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
</DynamicParamField>
<DynamicParamField body="invoice_template_id" type="string">
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
</DynamicParamField>
<DynamicParamField body="net_terms_days" type="integer">
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -32,6 +32,28 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
</DynamicParamField>
<DynamicParamField body="invoice_template_id" type="string">
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
</DynamicParamField>
<DynamicParamField body="net_terms_days" type="integer">
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="discounts" type="object[]">
List of discounts to apply to the immediate phase. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.
<Expandable title="properties">
<DynamicParamField body="reward_id" type="string">
The ID of the reward to apply as a discount.
</DynamicParamField>
<DynamicParamField body="promotion_code" type="string">
The promotion code to apply as a discount.
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -226,6 +226,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
</DynamicParamField>
<DynamicParamField body="invoice_template_id" type="string">
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
</DynamicParamField>
<DynamicParamField body="net_terms_days" type="integer">
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -346,6 +346,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
</DynamicParamField>
<DynamicParamField body="invoice_template_id" type="string">
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
</DynamicParamField>
<DynamicParamField body="net_terms_days" type="integer">
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -226,6 +226,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
</DynamicParamField>
<DynamicParamField body="invoice_template_id" type="string">
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
</DynamicParamField>
<DynamicParamField body="net_terms_days" type="integer">
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -346,6 +346,14 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
If true, finalizes the invoice so it can be sent to the customer. If false, keeps it as a draft for manual review.
</DynamicParamField>
<DynamicParamField body="invoice_template_id" type="string">
ID of an invoice template (configured in billing settings) whose footer (e.g. bank details) is applied to the invoice.
</DynamicParamField>
<DynamicParamField body="net_terms_days" type="integer">
Number of days the customer has to pay the invoice before it is due (Stripe days_until_due).
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -512,6 +512,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -827,6 +831,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -666,6 +666,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -981,6 +985,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -537,6 +537,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -852,6 +856,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -654,6 +654,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -969,6 +973,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -619,6 +619,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -933,6 +937,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -391,6 +391,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -705,6 +709,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -420,6 +420,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -734,6 +738,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -455,6 +455,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this subscription is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
@@ -769,6 +773,10 @@ import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
Number of units purchased.
</DynamicResponseField>
<DynamicResponseField name="scope" type="'customer' | 'entity'">
Whether this purchase is attached at the customer level or entity level.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>

View File

@@ -0,0 +1,79 @@
---
title: "Get Revenue Cat Keys"
openapi: "openapi POST /v1/platform.get_revenuecat_keys"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="organization_slug" type="string" required />
<DynamicParamField body="env" type="'test' | 'sandbox' | 'live'" required>
"test" and "sandbox" both target the sandbox environment
</DynamicParamField>
### Response
<DynamicResponseField name="apps" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="app_id" type="string" />
<DynamicResponseField name="app_type" type="string">
RevenueCat store type, e.g. test_store / app_store / play_store
</DynamicResponseField>
<DynamicResponseField name="name" type="string" />
<DynamicResponseField name="api_keys" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="id" type="string" />
<DynamicResponseField name="key" type="string">
The public SDK API key value
</DynamicResponseField>
<DynamicResponseField name="environment" type="string | null">
e.g. "production" / "sandbox"
</DynamicResponseField>
<DynamicResponseField name="app_id" type="string | null" />
<DynamicResponseField name="created_at" type="number" />
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="oauth_access_token" type="string | null">
Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.
</DynamicResponseField>
<ResponseExample>
```json 200
{
"apps": [
{
"app_id": "app1a2b3c4d",
"app_type": "test_store",
"name": "Acme (Test Store)",
"api_keys": [
{
"id": "apikey12345",
"key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ",
"environment": "production",
"app_id": "app1a2b3c4"
}
]
}
],
"oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"
}
```
</ResponseExample>

View File

@@ -0,0 +1,32 @@
---
title: "Link Revenue Cat"
openapi: "openapi POST /v1/platform.link_revenuecat"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="organization_slug" type="string" required />
<DynamicParamField body="env" type="'test' | 'live'" required />
<DynamicParamField body="project_name" type="string" required />
<DynamicParamField body="redirect_url" type="string" required />
### Response
<DynamicResponseField name="oauth_url" type="string" />
<ResponseExample>
```json 200
{
"oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"
}
```
</ResponseExample>

View File

@@ -0,0 +1,77 @@
---
title: "Sync Revenue Cat"
openapi: "openapi POST /v1/platform.sync_revenuecat"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="organization_slug" type="string" required />
<DynamicParamField body="env" type="'test' | 'sandbox' | 'live'" required>
"test" and "sandbox" both target the sandbox environment
</DynamicParamField>
<DynamicParamField body="product_ids" type="string[]">
Plans to push. Omit to sync every plan in the org/env.
</DynamicParamField>
### Response
<DynamicResponseField name="results" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="plan_id" type="string" />
<DynamicResponseField name="status" type="'synced' | 'skipped' | 'error'" />
<DynamicResponseField name="store_identifier" type="string" />
<DynamicResponseField name="apps" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="app_id" type="string" />
<DynamicResponseField name="app_type" type="string" />
<DynamicResponseField name="product" type="'created' | 'updated' | 'exists'" />
<DynamicResponseField name="store_push" type="'pushed' | 'failed' | 'skipped'" />
<DynamicResponseField name="price" type="'set' | 'skipped' | 'failed'" />
<DynamicResponseField name="message" type="string" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="message" type="string" />
</Expandable>
</DynamicResponseField>
<ResponseExample>
```json 200
{
"results": [
{
"plan_id": "pro",
"status": "synced",
"store_identifier": "autumn.sandbox.org_123.pro",
"apps": [
{
"app_id": "app_test",
"app_type": "test_store",
"product": "created",
"store_push": "skipped",
"price": "set"
}
]
}
]
}
```
</ResponseExample>

View File

@@ -482,6 +482,13 @@ components:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -519,6 +526,13 @@ components:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -2234,6 +2248,13 @@ paths:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -2272,6 +2293,13 @@ paths:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -3114,6 +3142,13 @@ paths:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -3152,6 +3187,13 @@ paths:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -3972,6 +4014,13 @@ paths:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -4010,6 +4059,13 @@ paths:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -8313,6 +8369,17 @@ paths:
default: true
description: If true, finalizes the invoice so it can be sent to the customer.
If false, keeps it as a draft for manual review.
invoice_template_id:
type: string
description: ID of an invoice template (configured in billing settings) whose
footer (e.g. bank details) is applied to the invoice.
net_terms_days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
exclusiveMinimum: 0
description: Number of days the customer has to pay the invoice before it is due
(Stripe days_until_due).
required:
- enabled
description: Invoice mode creates a draft or open invoice and sends it to the
@@ -8628,10 +8695,37 @@ paths:
default: true
description: If true, finalizes the invoice so it can be sent to the customer.
If false, keeps it as a draft for manual review.
invoice_template_id:
type: string
description: ID of an invoice template (configured in billing settings) whose
footer (e.g. bank details) is applied to the invoice.
net_terms_days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
exclusiveMinimum: 0
description: Number of days the customer has to pay the invoice before it is due
(Stripe days_until_due).
required:
- enabled
description: Invoice mode creates and sends an invoice instead of charging the
customer's payment method immediately for the first phase.
discounts:
type: array
items:
type: object
properties:
reward_id:
type: string
description: The ID of the reward to apply as a discount.
promotion_code:
type: string
description: The promotion code to apply as a discount.
title: AttachDiscount
description: A discount to apply. Can be either a reward ID or a promotion code.
description: List of discounts to apply to the immediate phase. Each discount
can be an Autumn reward ID, Stripe coupon ID, or Stripe
promotion code.
success_url:
type: string
description: URL to redirect to after successful checkout.
@@ -9654,6 +9748,17 @@ paths:
default: true
description: If true, finalizes the invoice so it can be sent to the customer.
If false, keeps it as a draft for manual review.
invoice_template_id:
type: string
description: ID of an invoice template (configured in billing settings) whose
footer (e.g. bank details) is applied to the invoice.
net_terms_days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
exclusiveMinimum: 0
description: Number of days the customer has to pay the invoice before it is due
(Stripe days_until_due).
required:
- enabled
description: Invoice mode creates a draft or open invoice and sends it to the
@@ -10420,6 +10525,17 @@ paths:
default: true
description: If true, finalizes the invoice so it can be sent to the customer.
If false, keeps it as a draft for manual review.
invoice_template_id:
type: string
description: ID of an invoice template (configured in billing settings) whose
footer (e.g. bank details) is applied to the invoice.
net_terms_days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
exclusiveMinimum: 0
description: Number of days the customer has to pay the invoice before it is due
(Stripe days_until_due).
required:
- enabled
description: Invoice mode creates a draft or open invoice and sends it to the
@@ -11327,6 +11443,17 @@ paths:
default: true
description: If true, finalizes the invoice so it can be sent to the customer.
If false, keeps it as a draft for manual review.
invoice_template_id:
type: string
description: ID of an invoice template (configured in billing settings) whose
footer (e.g. bank details) is applied to the invoice.
net_terms_days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
exclusiveMinimum: 0
description: Number of days the customer has to pay the invoice before it is due
(Stripe days_until_due).
required:
- enabled
description: Invoice mode creates a draft or open invoice and sends it to the
@@ -12418,6 +12545,17 @@ paths:
default: true
description: If true, finalizes the invoice so it can be sent to the customer.
If false, keeps it as a draft for manual review.
invoice_template_id:
type: string
description: ID of an invoice template (configured in billing settings) whose
footer (e.g. bank details) is applied to the invoice.
net_terms_days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
exclusiveMinimum: 0
description: Number of days the customer has to pay the invoice before it is due
(Stripe days_until_due).
required:
- enabled
description: Invoice mode creates a draft or open invoice and sends it to the
@@ -13104,6 +13242,17 @@ paths:
default: true
description: If true, finalizes the invoice so it can be sent to the customer.
If false, keeps it as a draft for manual review.
invoice_template_id:
type: string
description: ID of an invoice template (configured in billing settings) whose
footer (e.g. bank details) is applied to the invoice.
net_terms_days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
exclusiveMinimum: 0
description: Number of days the customer has to pay the invoice before it is due
(Stripe days_until_due).
required:
- enabled
description: Invoice mode creates a draft or open invoice and sends it to the
@@ -17247,6 +17396,13 @@ paths:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -17283,6 +17439,13 @@ paths:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -17737,6 +17900,13 @@ paths:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -17773,6 +17943,13 @@ paths:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -18272,6 +18449,13 @@ paths:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -18308,6 +18492,13 @@ paths:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -18843,6 +19034,13 @@ paths:
quantity:
type: number
description: Number of units of this subscription (for per-seat plans).
scope:
enum:
- customer
- entity
type: string
description: Whether this subscription is attached at the customer level or
entity level.
required:
- id
- plan_id
@@ -18879,6 +19077,13 @@ paths:
quantity:
type: number
description: Number of units purchased.
scope:
enum:
- customer
- entity
type: string
description: Whether this purchase is attached at the customer level or entity
level.
required:
- plan_id
- expires_at
@@ -19539,6 +19744,384 @@ paths:
code="REWARD10",
customer_id="cus_456",
)
/v1/platform.link_revenuecat:
post:
operationId: linkRevenueCat
description: Generate a RevenueCat OAuth URL for linking a project to an organization.
tags:
- platform
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
organization_slug:
type: string
minLength: 1
env:
enum:
- test
- live
type: string
project_name:
type: string
minLength: 1
maxLength: 255
redirect_url:
type: string
format: uri
required:
- organization_slug
- env
- project_name
- redirect_url
title: LinkRevenueCatParams
examples:
- &a77
organization_slug: acme
env: test
project_name: acme-mobile
redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat
example: *a77
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
oauth_url:
type: string
required:
- oauth_url
title: LinkRevenueCatResponse
examples:
- &a78
oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write
example: *a78
x-speakeasy-name-override: linkRevenueCat
parameters:
- *a5
x-codeSamples:
- lang: typescript
label: Typescript (SDK)
source: |-
import { Autumn } from 'autumn-js'
const autumn = new Autumn()
const result = await autumn.platform.linkRevenueCat({
organizationSlug: "acme",
env: "test",
projectName: "acme-mobile",
redirectUrl: "https://dashboard.useautumn.com/dev?tab=revenuecat",
});
- lang: python
label: Python (SDK)
source: >-
from autumn_sdk import Autumn
autumn = Autumn(secret_key="am_sk_test...")
res = autumn.platform.link_revenue_cat(
organization_slug="acme",
env="test",
project_name="acme-mobile",
redirect_url="https://dashboard.useautumn.com/dev?tab=revenuecat",
)
/v1/platform.sync_revenuecat:
post:
operationId: syncRevenueCat
description: Push an organization's plans into RevenueCat as products (creating
or renaming them across the project's apps) and set test-store prices
from each plan's price. Requires the org to have linked RevenueCat via
OAuth.
tags:
- platform
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
organization_slug:
type: string
minLength: 1
env:
enum:
- test
- sandbox
- live
type: string
description: '"test" and "sandbox" both target the sandbox environment'
product_ids:
type: array
items:
type: string
description: Plans to push. Omit to sync every plan in the org/env.
required:
- organization_slug
- env
title: SyncRevenueCatParams
examples:
- &a79
organization_slug: acme
env: test
product_ids:
- pro
- premium
example: *a79
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
plan_id:
type: string
status:
enum:
- synced
- skipped
- error
type: string
store_identifier:
type: string
apps:
type: array
items:
type: object
properties:
app_id:
type: string
app_type:
type: string
product:
enum:
- created
- updated
- exists
type: string
store_push:
enum:
- pushed
- failed
- skipped
type: string
price:
enum:
- set
- skipped
- failed
type: string
message:
type: string
required:
- app_id
- app_type
- product
message:
type: string
required:
- plan_id
- status
required:
- results
title: SyncRevenueCatResponse
examples:
- &a80
results:
- plan_id: pro
status: synced
store_identifier: autumn.sandbox.org_123.pro
apps:
- app_id: app_test
app_type: test_store
product: created
store_push: skipped
price: set
example: *a80
x-speakeasy-name-override: syncRevenueCat
parameters:
- *a5
x-codeSamples:
- lang: typescript
label: Typescript (SDK)
source: |-
import { Autumn } from 'autumn-js'
const autumn = new Autumn()
const result = await autumn.platform.syncRevenueCat({
organizationSlug: "acme",
env: "test",
productIds: [
"pro",
"premium",
],
});
- lang: python
label: Python (SDK)
source: |-
from autumn_sdk import Autumn
autumn = Autumn(secret_key="am_sk_test...")
res = autumn.platform.sync_revenue_cat(
organization_slug="acme",
env="test",
product_ids=[
"pro",
"premium",
],
)
/v1/platform.get_revenuecat_keys:
post:
operationId: getRevenueCatKeys
description: Retrieve a managed organization's RevenueCat public (SDK) API keys,
grouped by app — for the test store, App Store, and Google Play Store.
Use these to configure the RevenueCat SDK in the org's mobile app.
tags:
- platform
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
organization_slug:
type: string
minLength: 1
env:
enum:
- test
- sandbox
- live
type: string
description: '"test" and "sandbox" both target the sandbox environment'
required:
- organization_slug
- env
title: GetRevenueCatKeysParams
examples:
- &a81
organization_slug: acme
env: test
example: *a81
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
apps:
type: array
items:
type: object
properties:
app_id:
type: string
app_type:
type: string
description: RevenueCat store type, e.g. test_store / app_store / play_store
name:
type: string
api_keys:
type: array
items:
type: object
properties:
id:
type: string
key:
type: string
description: The public SDK API key value
environment:
anyOf:
- type: string
- type: "null"
description: e.g. "production" / "sandbox"
app_id:
anyOf:
- type: string
- type: "null"
created_at:
type: number
required:
- id
- key
additionalProperties: {}
required:
- app_id
- app_type
- name
- api_keys
oauth_access_token:
anyOf:
- type: string
- type: "null"
description: Freshly-refreshed RevenueCat OAuth access token for the org (null
for api-key orgs). The refresh token is never exposed —
call this endpoint again for a new access token.
required:
- apps
- oauth_access_token
title: GetRevenueCatKeysResponse
examples:
- &a82
apps:
- app_id: app1a2b3c4d
app_type: test_store
name: Acme (Test Store)
api_keys:
- id: apikey12345
key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ
environment: production
app_id: app1a2b3c4
oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ
example: *a82
x-speakeasy-name-override: getRevenueCatKeys
parameters:
- *a5
x-codeSamples:
- lang: typescript
label: Typescript (SDK)
source: |-
import { Autumn } from 'autumn-js'
const autumn = new Autumn()
const result = await autumn.platform.getRevenueCatKeys({
organizationSlug: "acme",
env: "test",
});
- lang: python
label: Python (SDK)
source: |-
from autumn_sdk import Autumn
autumn = Autumn(secret_key="am_sk_test...")
res = autumn.platform.get_revenue_cat_keys(
organization_slug="acme",
env="test",
)
security:
- secretKey: []
x-speakeasy-globals:

View File

@@ -4,6 +4,54 @@ mode: "center"
description: "Some new things we've shipped at Autumn HQ"
---
<Update label="May 29th 2026">
## Autumn MCP server
Autumn now ships an official **Model Context Protocol (MCP) server**, so AI assistants like Claude Desktop and Cursor can read and act on your Autumn data directly. The server is generated against the public API and respects your existing API key scopes (`customers:read`, `plans:read`, `billing:read`, `billing:write`) — no new permission model to learn.
Install it as a Claude Desktop extension or wire it into Cursor with a single deeplink. Use it to look up customers, inspect plans, preview attaches, and run billing updates from your assistant.
## Org-level usage alerts
Usage alerts can now be configured **once at the org level** and applied across every customer, instead of being attached plan by plan. Set a threshold and channel from settings, and Autumn fires alerts whenever any customer crosses the bar. The [`balances.usage_alert.triggered` webhook](/api-reference/webhooks/balancesUsageAlertTriggered) fires for both per-plan and org-level alerts.
## Vercel Marketplace: invoice mode and resource logs
The Vercel integration now supports **invoice-mode billing** end to end and surfaces **per-resource logs** in the dashboard, so you can debug a Vercel customer's provisioning, auto top-ups, and invoice flow without leaving Autumn. The frontend was rebuilt around the new resource model, with fallbacks for identity edge cases.
See the [Vercel Marketplace guide](/documentation/external-providers/vercel-marketplace).
<AccordionGroup>
<Accordion title="Improvements">
- Official Autumn MCP server for Claude Desktop, Cursor, and other MCP clients — [#1715](https://github.com/useautumn/autumn/pull/1715), [#1740](https://github.com/useautumn/autumn/pull/1740), [#1741](https://github.com/useautumn/autumn/pull/1741)
- Org-level usage alerts with shared thresholds across customers — [#1707](https://github.com/useautumn/autumn/pull/1707)
- Vercel Marketplace invoice mode, resource logs, and refreshed frontend — [#1711](https://github.com/useautumn/autumn/pull/1711)
- Preflight tax-address check before charging customers in taxable regions — [#1699](https://github.com/useautumn/autumn/pull/1699)
- Automatically void uncollectible Stripe invoices instead of leaving them open — [#1732](https://github.com/useautumn/autumn/pull/1732)
- New plan-version filters for [custom-plan migrations](/documentation/customers/custom-plans) — [#1718](https://github.com/useautumn/autumn/pull/1718)
- Faster customer detail loads and smoother large entity lists — [#1712](https://github.com/useautumn/autumn/pull/1712)
- Customer list filter by entity ID — [#1688](https://github.com/useautumn/autumn/pull/1688)
- Refreshed dashboard theme and appearance controls — [#1679](https://github.com/useautumn/autumn/pull/1679)
- Optimised product counts in the customer table — [#1690](https://github.com/useautumn/autumn/pull/1690)
- Mobile fixes for the customer list view — [#1689](https://github.com/useautumn/autumn/pull/1689)
</Accordion>
<Accordion title="Bug fixes">
- Stripe checkout no longer drops carry-over balances on plan changes — [#1708](https://github.com/useautumn/autumn/pull/1708)
- Trial grants are now excluded from reward eligibility — [#1730](https://github.com/useautumn/autumn/pull/1730)
- RevenueCat sync no longer fails with "entities not found" — [#1729](https://github.com/useautumn/autumn/pull/1729)
- Tax IDs now render correctly in the attach preview — [#1728](https://github.com/useautumn/autumn/pull/1728)
- Invalid email addresses are now rejected with a clearer error — [#1722](https://github.com/useautumn/autumn/pull/1722)
- Sandbox usage alerts now respect their configured threshold — [#1706](https://github.com/useautumn/autumn/pull/1706)
- Proration toggle now shows even when the customer has a past trial — [#1704](https://github.com/useautumn/autumn/pull/1704)
- One-off prepaid items upgrade correctly without double carry-over — [#1676](https://github.com/useautumn/autumn/pull/1676), [#1695](https://github.com/useautumn/autumn/pull/1695)
- Subscription metadata is preserved through checkout — [#1719](https://github.com/useautumn/autumn/pull/1719)
- Price dropdown no longer loses its selected value when reopened — [#1720](https://github.com/useautumn/autumn/pull/1720)
- Vercel auto top-ups no longer fail to process the resulting invoice — multiple fixes ([#1725](https://github.com/useautumn/autumn/pull/1725))
</Accordion>
</AccordionGroup>
</Update>
<Update label="May 28th 2026">
## Batch track and async track

View File

@@ -33,16 +33,16 @@ bun add @useautumn/ai-sdk
#### 2. Wrap your model
Use `withTokenTracking` to wrap any AI SDK language model. It intercepts generate and stream calls, reads the token usage from the response, and reports it to Autumn automatically.
Use `withAutumn` to wrap any AI SDK language model. It intercepts generate and stream calls, reads the token usage from the response, and reports it to Autumn automatically.
```typescript
import { Autumn } from "autumn-js";
import { anthropic } from "@ai-sdk/anthropic";
import { withTokenTracking } from "@useautumn/ai-sdk";
import { withAutumn } from "@useautumn/ai-sdk";
const autumn = new Autumn({ secretKey: "am_sk_test_1234" });
const model = withTokenTracking({
const model = withAutumn({
autumn,
model: anthropic("claude-sonnet-4-5-20250514"),
customerId: "user_123",
@@ -89,7 +89,7 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
const openrouter = createOpenRouter();
const model = withTokenTracking({
const model = withAutumn({
autumn,
model: openrouter("anthropic/claude-opus-4-6"),
customerId: "user_123",
@@ -116,12 +116,12 @@ const model = withTokenTracking({
import { Autumn } from "autumn-js";
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { withTokenTracking } from "@useautumn/ai-sdk";
import { withAutumn } from "@useautumn/ai-sdk";
const autumn = new Autumn({ secretKey: process.env.AUTUMN_SECRET_KEY! });
async function chat(customerId: string, message: string) {
const model = withTokenTracking({
const model = withAutumn({
autumn,
model: openai("gpt-4o"),
customerId,

53
apps/leaf/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Autumn Leaf
Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/mcpRouter.ts`).
Local Slack testing uses a normal Slack app in a development workspace. Keep the app undistributed while testing.
## Slack test app
1. Start the stable local tunnel for the server. It forwards Slack requests to the chat app in local development:
```sh
bun run chat:tunnel
```
2. Start Autumn with the same public URL:
```sh
NGROK_URL=https://c.autumn.ngrok.app bun d
```
`bun d` derives `CHAT_URL`, `SLACK_BOT_URL`, and `SLACK_REDIRECT_URI` from
`NGROK_URL`, so the Slack OAuth redirect becomes
`https://c.autumn.ngrok.app/slack/oauth/callback`. This exact URL must be in
the Slack app's OAuth redirect URLs.
The chat SDK stores its own subscriptions, locks, and queues in Postgres. By
default it uses the same `DATABASE_URL` host with the database name changed to
`chat`; set `CHAT_STATE_DATABASE_URL` to override this. `bun dev:services up`
creates the local `chat` database. The `@chat-adapter/state-pg` package creates
its state tables automatically on connect, so there is no separate migration
command for the chat state database.
3. Create a Slack app at https://api.slack.com/apps using `slack-manifest.example.json`.
For production, use `slack-manifest.prod.json`. It points Slack at `https://api.useautumn.com/slack/*`, which the API proxies to the chat service.
4. Copy the Slack app credentials into the local environment:
```sh
SLACK_CLIENT_ID=...
SLACK_CLIENT_SECRET=...
SLACK_SIGNING_SECRET=...
```
5. In Autumn, open Settings -> Integrations and click Add Slack.
6. Test in Slack by DMing the app:
```txt
list customers
```
For billing changes, the app should post a preview with Approve and Cancel buttons before calling a write tool.

42
apps/leaf/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@autumn/leaf",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"eval": "zsh -lc 'braintrust eval tests/evals/**/*.eval.ts --external-packages @mastra/mcp @mastra/core @mastra/braintrust @mastra/observability \"$@\"' --",
"eval:mcp": "zsh -lc 'braintrust eval tests/evals/mcp/**/*.eval.ts --external-packages @mastra/mcp @mastra/core @mastra/braintrust @mastra/observability \"$@\"' --",
"start": "bun src/index.ts",
"test": "bun test tests/unit",
"ts": "tsc --noEmit"
},
"dependencies": {
"@autumn/auth": "workspace:*",
"@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",
"@chat-adapter/state-pg": "^4.29.0",
"@hono/node-server": "^1.19.5",
"@mastra/braintrust": "^1.1.3",
"@mastra/core": "^1.36.0",
"@mastra/mcp": "^1.8.0",
"@mastra/observability": "^1.14.1",
"@mendable/firecrawl-js": "^4.25.1",
"autoevals": "^0.0.132",
"braintrust": "^3.14.0",
"chat": "^4.29.0",
"date-fns": "^4.1.0",
"drizzle-orm": "catalog:",
"e2b": "^2.8.4",
"hono": "4.12.7",
"postgres": "catalog:",
"zod": "^3.25.23"
},
"devDependencies": {
"@types/bun": "^1.3.1",
"@types/node": "^25.0.7",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,53 @@
{
"display_information": {
"name": "Autumn Chat Local"
},
"features": {
"bot_user": {
"display_name": "Autumn",
"always_online": false
}
},
"oauth_config": {
"redirect_urls": ["https://c.autumn.ngrok.app/slack/oauth/callback"],
"scopes": {
"bot": [
"app_mentions:read",
"assistant:write",
"channels:history",
"channels:read",
"chat:write",
"files:read",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",
"mpim:history",
"mpim:read",
"users:read"
]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://c.autumn.ngrok.app/slack/events",
"bot_events": [
"app_mention",
"assistant_thread_started",
"assistant_thread_context_changed",
"message.channels",
"message.groups",
"message.im",
"message.mpim"
]
},
"interactivity": {
"is_enabled": true,
"request_url": "https://c.autumn.ngrok.app/slack/interactions"
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"is_hosted": false
}
}

View File

@@ -0,0 +1,53 @@
{
"display_information": {
"name": "Autumn"
},
"features": {
"bot_user": {
"display_name": "Autumn",
"always_online": false
}
},
"oauth_config": {
"redirect_urls": ["https://api.useautumn.com/slack/oauth/callback"],
"scopes": {
"bot": [
"app_mentions:read",
"assistant:write",
"channels:history",
"channels:read",
"chat:write",
"files:read",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",
"mpim:history",
"mpim:read",
"users:read"
]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://api.useautumn.com/slack/events",
"bot_events": [
"app_mention",
"assistant_thread_started",
"assistant_thread_context_changed",
"message.channels",
"message.groups",
"message.im",
"message.mpim"
]
},
"interactivity": {
"is_enabled": true,
"request_url": "https://api.useautumn.com/slack/interactions"
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"is_hosted": false
}
}

View File

@@ -0,0 +1,246 @@
import type { AutumnLogger } from "@autumn/logging";
import { AppEnv } from "@autumn/shared";
import { Agent } from "@mastra/core/agent";
import type { MessageListInput } from "@mastra/core/agent/message-list";
import { Mastra } from "@mastra/core/mastra";
import { InMemoryStore } from "@mastra/core/storage";
import { z } from "zod";
import { createLeafTracingOptions } from "../internal/observability/leafTracingOptions.js";
import { env as chatEnv } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import { createMastraBraintrustObservability } from "../providers/braintrust/index.js";
import { createE2bSandboxProvider } from "../providers/e2b/e2bSandboxProvider.js";
import type { ChatContextMessage } from "../types.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
import { sandboxConfig } from "./sandbox/config.js";
import { createSandboxTools } from "./sandbox/createSandboxTools.js";
import { agentDocUris, createAutumnChatAgent } from "./chatAgent.js";
export { agentDocUris, createAutumnChatAgent } from "./chatAgent.js";
const envSelectionSchema = z.strictObject({
env: z.nativeEnum(AppEnv),
});
export const getDefaultChatEnv = () =>
process.env.NODE_ENV === "production" ? AppEnv.Live : AppEnv.Sandbox;
const recentMessageContext = (messages: ChatContextMessage[] = []) =>
messages.map((message) => ({
role: message.isBot === true ? ("assistant" as const) : ("user" as const),
content: `${message.author}${message.isBot === true ? " (bot)" : ""}: ${message.text}`,
}));
export const selectChatEnv = async ({
logger = rootLogger,
message,
recentMessages,
select,
}: {
logger?: AutumnLogger;
message: string;
recentMessages?: ChatContextMessage[];
select?: () => Promise<unknown> | unknown;
}) => {
if (select) {
const env = envSelectionSchema.parse(await select()).env;
logger.debug("Selected chat environment from override", {
event: "leaf.chat_env_selected",
context: { env },
data: { source: "override" },
});
return env;
}
const agent = new Agent({
id: "autumn-chat-env",
name: "Autumn Chat Env",
instructions: `Choose the Autumn environment for the latest user request. Default to ${getDefaultChatEnv()}. Use the other environment only when the user clearly asks for it.`,
model: chatEnv.CHAT_MODEL,
});
const output = await agent.generate(message, {
maxSteps: 1,
structuredOutput: {
schema: envSelectionSchema,
instructions: `Return ${getDefaultChatEnv()} unless the latest user request clearly asks to use the other environment.`,
},
context: [...recentMessageContext(recentMessages)],
});
logger.debug("Selected chat environment from model", {
event: "leaf.chat_env_selected",
context: { env: output.object.env },
data: { source: "model" },
});
return output.object.env;
};
const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
const resources = await Promise.allSettled(
agentDocUris.map((uri) => mcp.resources.read("autumn", uri)),
);
return resources
.flatMap((result) =>
result.status === "fulfilled"
? result.value.contents.flatMap((content) =>
"text" in content ? [content.text] : [],
)
: [],
)
.join("\n\n");
};
export const runChatAgent = async ({
token,
env,
logger = rootLogger,
message,
channelId,
threadId,
resourceId,
onAction,
provider,
workspaceId,
recentMessages,
agentRunId,
orgSlug,
}: {
token: string;
env: AppEnv;
logger?: AutumnLogger;
message: MessageListInput;
channelId: string;
onAction?: (message: string) => Promise<void> | void;
threadId: string;
resourceId: string;
provider: string;
workspaceId: string;
agentRunId?: string;
orgSlug?: string | null;
recentMessages?: ChatContextMessage[];
}) => {
const mcp = createAutumnMcpClient({
token,
appEnv: env,
options: { requireApproval: true },
});
let previewApproval:
| {
toolName: string;
toolArgs: Record<string, unknown>;
preview: unknown;
}
| undefined;
try {
logger.info("Starting chat agent", {
event: "leaf.agent_started",
context: {
env,
org_id: resourceId,
provider,
},
data: {
thread_id: threadId,
},
});
await onAction?.("Loading Autumn tools and guidance");
const [tools, docsText] = await Promise.all([
getAutumnMcpTools({
mcp,
options: {
applyApprovalPolicy: true,
logger,
onToolCall: onAction,
onPreview: (approval) => {
previewApproval = approval;
},
},
}),
readDocs(mcp),
]);
const firecrawlTools = createFirecrawlTools({
apiKey: chatEnv.FIRECRAWL_API_KEY,
onAction,
});
const sandboxTools =
sandboxConfig.enabled && chatEnv.E2B_API_KEY
? createSandboxTools({
logger,
onAction,
provider: createE2bSandboxProvider({
apiKey: chatEnv.E2B_API_KEY,
context: {
channelId,
env,
orgId: resourceId,
provider,
threadId,
workspaceId,
},
sessionTimeoutMs: sandboxConfig.sessionTimeoutMs,
}),
})
: {};
if (sandboxConfig.enabled && !chatEnv.E2B_API_KEY) {
logger.warn("Sandbox is enabled without an E2B API key", {
event: "leaf.sandbox_disabled",
});
}
await onAction?.("Reasoning over the request");
const agent = createAutumnChatAgent({
docsText,
env,
model: chatEnv.CHAT_MODEL,
tools: { ...tools, ...firecrawlTools, ...sandboxTools },
});
const mastra = new Mastra({
agents: { chat: agent },
environment: process.env.NODE_ENV,
logger: false,
observability: createMastraBraintrustObservability(),
storage: new InMemoryStore({ id: `leaf-chat-${crypto.randomUUID()}` }),
});
const chatAgent = mastra.getAgent("chat");
const output = await chatAgent.generate(message, {
maxSteps: 8,
context: [
{
role: "system",
content: [
`${provider} thread: ${threadId}. Autumn resource: ${resourceId}.`,
"Answer the latest user message. Use prior thread messages only as context.",
]
.filter(Boolean)
.join("\n\n"),
},
...recentMessageContext(recentMessages),
],
tracingOptions: createLeafTracingOptions({
agentRunId,
channelId,
env,
orgId: resourceId,
orgSlug,
provider,
source: "prod",
threadId,
workspaceId,
}),
});
logger.info("Completed chat agent", {
event: "leaf.agent_completed",
context: { env },
data: {
finish_reason: output.finishReason,
run_id: output.runId,
},
});
return { ...output, env, previewApproval };
} finally {
await mcp.disconnect();
logger.debug("Disconnected Autumn MCP client", {
event: "leaf.mcp_client_disconnected",
});
}
};

View File

@@ -0,0 +1,139 @@
import type { AutumnLogger } from "@autumn/logging";
import type { MessageListInput } from "@mastra/core/agent/message-list";
import type { Attachment } from "chat";
import { logger as rootLogger } from "../lib/logger.js";
const MAX_ATTACHMENTS = 4;
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
const SUPPORTED_MIME_TYPES = new Set([
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
]);
type AttachmentFetchFallback = ({
attachment,
}: {
attachment: Attachment;
}) => Promise<Buffer | null>;
const getAttachmentLabel = (attachment: Attachment) =>
attachment.name ?? attachment.mimeType ?? "unnamed attachment";
const isSupportedAttachment = (attachment: Attachment) =>
attachment.mimeType ? SUPPORTED_MIME_TYPES.has(attachment.mimeType) : false;
const fetchAttachmentData = async ({
attachment,
fetchFallback,
}: {
attachment: Attachment;
fetchFallback?: AttachmentFetchFallback;
}) => {
if (attachment.data) {
return attachment.data instanceof Blob
? Buffer.from(await attachment.data.arrayBuffer())
: Buffer.from(attachment.data);
}
if (attachment.fetchData) return attachment.fetchData();
return fetchFallback?.({ attachment }) ?? null;
};
export const prepareAttachmentMessage = async ({
attachments = [],
fetchFallback,
logger = rootLogger,
text,
}: {
attachments?: Attachment[];
fetchFallback?: AttachmentFetchFallback;
logger?: AutumnLogger;
text: string;
}) => {
const notes: string[] = [];
const parts: Array<{
data: Buffer;
filename?: string;
mediaType: string;
type: "file";
}> = [];
for (const attachment of attachments.slice(0, MAX_ATTACHMENTS)) {
const label = getAttachmentLabel(attachment);
if (!isSupportedAttachment(attachment)) {
notes.push(`Skipped ${label}: unsupported file type.`);
continue;
}
if (attachment.size && attachment.size > MAX_ATTACHMENT_BYTES) {
notes.push(`Skipped ${label}: file is too large.`);
continue;
}
try {
const data = await fetchAttachmentData({ attachment, fetchFallback });
if (!data) {
notes.push(`Skipped ${label}: file could not be downloaded.`);
continue;
}
if (data.byteLength > MAX_ATTACHMENT_BYTES) {
notes.push(`Skipped ${label}: downloaded file is too large.`);
continue;
}
parts.push({
type: "file",
data,
filename: attachment.name,
mediaType: attachment.mimeType as string,
});
} catch (error) {
logger.warn("Could not prepare Slack attachment", {
event: "leaf.slack_attachment_prepare_failed",
data: {
name: attachment.name,
mime_type: attachment.mimeType,
size: attachment.size,
},
error,
});
notes.push(`Skipped ${label}: file could not be processed.`);
}
}
if (attachments.length > MAX_ATTACHMENTS) {
notes.push(
`Skipped ${attachments.length - MAX_ATTACHMENTS} extra attachment(s).`,
);
}
const userText = [
text.trim() || "Please answer using the attached Slack file(s).",
notes.length ? `Attachment processing notes:\n${notes.join("\n")}` : null,
]
.filter((line): line is string => Boolean(line))
.join("\n\n");
const message = [
{
role: "user" as const,
content: [...parts, { type: "text" as const, text: userText }],
},
] satisfies MessageListInput;
return {
attachmentCount: parts.length,
envSelectionText: [
text,
attachments.length
? `Slack attachments: ${attachments
.map((attachment) => getAttachmentLabel(attachment))
.join(", ")}`
: null,
notes.length ? `Attachment notes: ${notes.join(" ")}` : null,
]
.filter((line): line is string => Boolean(line))
.join("\n\n"),
message,
notes,
};
};

View File

@@ -0,0 +1,50 @@
import type { AppEnv } from "@autumn/shared";
import { Agent } from "@mastra/core/agent";
import type { ToolsInput } from "@mastra/core/agent";
export const agentDocUris = [
"autumn://docs/tool-composition",
"autumn://docs/feature-catalog",
"autumn://docs/querying-plans",
"autumn://docs/querying-customers",
"autumn://docs/schedules",
"autumn://docs/balances",
"autumn://docs/billing-safety",
"autumn://docs/request-logs",
"autumn://docs/request-log-customers",
"autumn://docs/request-log-balances",
"autumn://docs/request-log-billing",
"autumn://docs/request-log-stripe-webhooks",
"autumn://docs/request-log-analytics",
];
export const autumnChatInstructions = `You are Autumn Chat.
Use Autumn MCP tools for customer, plan, balance, schedule, and billing work.
Use web search only for current or external web context. Never use web search for Autumn customer, plan, billing, balance, or schedule state.
When web content influences the answer, cite the source URLs.
Prefer searchWeb first, then scrapeUrl only for the most relevant result.
Use listFeatures only when creating/customizing plan items or setting non-zero prepaid feature quantities and feature ids/types are not already known; never invent feature ids.
Use the sandbox only for short parsing, calculation, transformation, and file-analysis tasks. Never send secrets to the sandbox, never use it for Autumn writes, and treat sandbox output as advisory.
Preview billing-impacting changes first, summarize the preview in short Slack-friendly bullets, then call the matching write tool with the same request args.
When Autumn responses include epoch millisecond timestamps, use epochMillisecondsToDate before explaining those timestamps to a user.
Treat Slack PDFs and images attached to the latest message as part of the user's request. If an attachment was skipped or unavailable, say so briefly instead of pretending to have read it.
The runtime pauses destructive tools for approval before execution, so do not ask for confirmation in plain text.`;
export const createAutumnChatAgent = ({
docsText,
env,
model,
tools,
}: {
docsText: string;
env: AppEnv;
model: string;
tools: ToolsInput;
}) =>
new Agent({
id: "autumn-chat",
name: "Autumn Chat",
instructions: `${autumnChatInstructions}\n\nCurrent Autumn environment: ${env}.\n\n${docsText}`,
model,
tools,
});

View File

@@ -0,0 +1,89 @@
import { createTool } from "@mastra/core/tools";
import Firecrawl from "@mendable/firecrawl-js";
import { z } from "zod";
type FirecrawlClient = {
search: (
query: string,
options?: { limit?: number; sources?: ["web"] },
) => Promise<{ web?: unknown[] }>;
scrape: (
url: string,
options?: { formats?: ["markdown"] },
) => Promise<{
markdown?: string;
metadata?: { title?: string; sourceURL?: string };
}>;
};
const maxSearchResults = 5;
const maxMarkdownLength = 12_000;
const trimMarkdown = (markdown = "") =>
markdown
.replace(/\n{3,}/g, "\n\n")
.trim()
.slice(0, maxMarkdownLength);
const stringField = (value: unknown, field: string) =>
value && typeof value === "object"
? (value as Record<string, unknown>)[field]
: undefined;
export const createFirecrawlTools = ({
apiKey,
client,
onAction,
}: {
apiKey: string;
client?: FirecrawlClient;
onAction?: (message: string) => Promise<void> | void;
}): Record<string, ReturnType<typeof createTool>> => {
const firecrawl = client ?? new Firecrawl({ apiKey });
return {
searchWeb: createTool({
id: "searchWeb",
description:
"Search the public web for current or external information. Use Autumn MCP tools instead for Autumn customer, plan, billing, balance, or schedule data.",
inputSchema: z
.object({
query: z.string().min(1),
limit: z.number().int().positive().max(maxSearchResults).optional(),
})
.strict(),
execute: async ({ query, limit = maxSearchResults }) => {
await onAction?.("Searching web");
const results = await firecrawl.search(query, {
limit,
sources: ["web"],
});
return {
results: (results.web ?? []).slice(0, limit).map((result) => ({
title:
stringField(result, "title") ??
stringField(result, "url") ??
"Untitled",
url: stringField(result, "url"),
description: stringField(result, "description"),
})),
};
},
}),
scrapeUrl: createTool({
id: "scrapeUrl",
description:
"Read one public web page as markdown after searchWeb identifies a relevant URL.",
inputSchema: z.object({ url: z.string().url() }).strict(),
execute: async ({ url }) => {
await onAction?.("Reading page");
const page = await firecrawl.scrape(url, { formats: ["markdown"] });
return {
title: page.metadata?.title,
url: page.metadata?.sourceURL ?? url,
markdown: trimMarkdown(page.markdown),
};
},
}),
};
};

181
apps/leaf/src/agent/mcp.ts Normal file
View File

@@ -0,0 +1,181 @@
import { isSecretKeyPrefix } from "@autumn/auth";
import type { AutumnLogger } from "@autumn/logging";
import type { AppEnv } from "@autumn/shared";
import { MCPClient } from "@mastra/mcp";
import { env } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
type AutumnTool = {
execute?: (
args: Record<string, unknown>,
...rest: unknown[]
) => Promise<unknown>;
mcp?: { annotations?: { destructiveHint?: boolean } };
requireApproval?: boolean;
needsApprovalFn?: unknown;
};
type ToolOptions = {
applyApprovalPolicy?: boolean;
logger?: AutumnLogger;
onToolCall?: (message: string) => Promise<void> | void;
onPreview?: (approval: {
toolName: string;
toolArgs: Record<string, unknown>;
preview: unknown;
}) => void;
};
const withAuthFetch =
({ appEnv, token }: { appEnv: AppEnv; token: string }) =>
(input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${token}`);
headers.set("x-autumn-environment", appEnv);
if (isSecretKeyPrefix({ token })) {
headers.set("secret-key", token);
}
return fetch(input, { ...init, headers });
};
export const createAutumnMcpClient = ({
token,
appEnv,
options = {},
}: {
token: string;
appEnv: AppEnv;
options?: { requireApproval?: boolean };
}) => {
const fetchWithAuth = withAuthFetch({ appEnv, token });
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"x-autumn-environment": appEnv,
};
if (isSecretKeyPrefix({ token })) {
headers["secret-key"] = token;
}
return new MCPClient({
id: `autumn-${token.slice(0, 14)}`,
servers: {
autumn: {
url: new URL("/mcp", env.MCP_SERVER_URL),
requestInit: { headers },
eventSourceInit: { fetch: fetchWithAuth },
fetch: fetchWithAuth,
requireToolApproval: options.requireApproval
? ({ annotations }) => annotations?.destructiveHint === true
: false,
},
},
});
};
const formatToolAction = ({
toolName,
args,
}: {
toolName: string;
args: Record<string, unknown>;
}) => {
const request =
args.request && typeof args.request === "object"
? (args.request as Record<string, unknown>)
: args;
const details = [
["customer", request.customer_id],
["plan", request.plan_id],
["entity", request.entity_id],
["search", request.search],
].flatMap(([label, value]) =>
typeof value === "string" && value ? [`${label}: ${value}`] : [],
);
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
};
export const getAutumnMcpTools = async ({
mcp,
options = {},
}: {
mcp: MCPClient;
options?: ToolOptions;
}) => {
const logger = options.logger ?? rootLogger;
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
if (Object.keys(errors).length) {
logger.error("Could not load Autumn MCP tools", {
event: "leaf.mcp_tools_load_failed",
data: { errors },
});
throw new Error(
`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`,
);
}
const tools = (toolsets.autumn ?? {}) as Record<string, AutumnTool>;
logger.info("Loaded Autumn MCP tools", {
event: "leaf.mcp_tools_loaded",
data: {
tool_count: Object.keys(tools).length,
},
});
for (const [toolName, tool] of Object.entries(tools)) {
if (options.applyApprovalPolicy) {
tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true;
if (!tool.requireApproval) tool.needsApprovalFn = undefined;
}
if (tool.execute && (options.onToolCall || options.onPreview)) {
const execute = tool.execute.bind(tool);
tool.execute = async (args, ...rest) => {
logger.info("Calling Autumn MCP tool", {
event: "leaf.mcp_tool_called",
tool: toolName,
});
await options.onToolCall?.(formatToolAction({ toolName, args }));
const result = await execute(args, ...rest);
const writeTool = getWriteToolForPreview(toolName);
if (writeTool) {
logger.info("Captured Autumn MCP preview", {
event: "leaf.mcp_preview_captured",
tool: writeTool,
data: {
preview_tool: toolName,
},
});
options.onPreview?.({
toolName: writeTool,
toolArgs: args,
preview: result,
});
}
return result;
};
}
}
return tools;
};
export const executeAutumnMcpTool = async ({
env,
token,
toolName,
args,
}: {
env: AppEnv;
token: string;
toolName: string;
args: Record<string, unknown>;
}) => {
const mcp = createAutumnMcpClient({ token, appEnv: env });
try {
const tools = await getAutumnMcpTools({ mcp });
const tool = tools[toolName.replace(/^autumn_/, "")];
if (!tool?.execute) throw new Error(`Unknown Autumn MCP tool: ${toolName}`);
return await tool.execute(args);
} finally {
await mcp.disconnect();
}
};

View File

@@ -0,0 +1,72 @@
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
import { logger as rootLogger } from "../lib/logger.js";
import { agentOutputSchema, type BotMessage } from "../types.js";
import { runChatAgent, selectChatEnv } from "./agent.js";
import { prepareAttachmentMessage } from "./attachments.js";
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
new Promise<T>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Chat agent timed out")),
ms,
);
promise.then(resolve, reject).finally(() => clearTimeout(timeout));
});
export const runMessage = async ({
agentRunId,
attachmentFetchFallback,
attachments,
installation,
logger = rootLogger,
onAction,
recentMessages,
text,
channelId,
threadId,
}: BotMessage) =>
withTimeout(
(async () => {
const preparedMessage = await prepareAttachmentMessage({
attachments,
fetchFallback: attachmentFetchFallback,
logger,
text,
});
const env = await selectChatEnv({
message: preparedMessage.envSelectionText,
recentMessages,
logger,
});
logger.info("Selected chat environment", {
event: "leaf.chat_env_selected",
context: {
env,
org_id: installation.org_id,
provider: installation.provider,
},
});
const token = await getInstallationOAuthAccessToken({
installation,
env,
});
return agentOutputSchema.parse(
await runChatAgent({
token,
env,
logger,
message: preparedMessage.message,
onAction,
channelId,
threadId,
agentRunId,
resourceId: installation.org_id,
orgSlug: installation.org_slug,
provider: installation.provider,
workspaceId: installation.workspace_id,
recentMessages,
}),
);
})(),
60_000,
);

View File

@@ -0,0 +1,4 @@
export const sandboxConfig = {
enabled: true,
sessionTimeoutMs: 10 * 60 * 1000,
};

View File

@@ -0,0 +1,83 @@
import type { AutumnLogger } from "@autumn/logging";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { logger as rootLogger } from "../../lib/logger.js";
import {
assertSafeSandboxCommand,
sandboxLimits,
sanitizeReturnFiles,
sanitizeSandboxFiles,
truncateSandboxResult,
} from "./guardrails.js";
import type { SandboxProvider } from "./types.js";
const sandboxFileSchema = z
.object({
path: z.string().min(1),
content: z.string(),
})
.strict();
export const createSandboxTools = ({
logger = rootLogger,
onAction,
provider,
}: {
logger?: AutumnLogger;
onAction?: (message: string) => Promise<void> | void;
provider: SandboxProvider;
}): Record<string, ReturnType<typeof createTool>> => ({
runSandboxCommand: createTool({
id: "runSandboxCommand",
description:
"Run a short command in an isolated sandbox for parsing, calculations, JSON/CSV transforms, and file analysis. Do not use it for Autumn writes, credentials, secrets, or direct API calls.",
inputSchema: z
.object({
task: z.string().min(1),
command: z.string().min(1),
files: z
.array(sandboxFileSchema)
.max(sandboxLimits.maxFiles)
.optional(),
returnFiles: z
.array(z.string().min(1))
.max(sandboxLimits.maxFiles)
.optional(),
})
.strict(),
execute: async ({ task, command, files = [], returnFiles = [] }) => {
await onAction?.("Running sandbox analysis");
const startedAt = Date.now();
const safeCommand = assertSafeSandboxCommand(command);
const safeFiles = sanitizeSandboxFiles(files);
const safeReturnFiles = sanitizeReturnFiles(returnFiles);
logger.info("Calling sandbox tool", {
event: "leaf.sandbox_tool_called",
data: {
file_count: safeFiles.length,
return_file_count: safeReturnFiles.length,
command_length: safeCommand.length,
task_length: task.length,
},
});
const result = truncateSandboxResult(
await provider.run({
command: safeCommand,
files: safeFiles,
returnFiles: safeReturnFiles,
timeoutMs: sandboxLimits.timeoutMs,
}),
);
logger.info("Completed sandbox tool", {
event: "leaf.sandbox_tool_completed",
data: {
duration_ms: Date.now() - startedAt,
file_count: result.files.length,
exit_code: result.exitCode,
timed_out: result.timedOut,
},
});
return result;
},
}),
});

View File

@@ -0,0 +1,106 @@
import path from "node:path";
import type { SandboxFile, SandboxRunResult } from "./types.js";
export const sandboxLimits = {
maxFiles: 5,
maxInputBytes: 256 * 1024,
maxOutputBytes: 24 * 1024,
maxReturnedFileBytes: 24 * 1024,
timeoutMs: 20_000,
workDir: "/work",
};
const secretPatterns = [
/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/i,
/\bxox[baprs]-[A-Za-z0-9-]{10,}/i,
/\bsk_(?:live|test|proj)?_[A-Za-z0-9]{12,}/i,
/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b/,
/^\s*[A-Z0-9_]*(?:SECRET|TOKEN|API_KEY|PASSWORD)[A-Z0-9_]*\s*=\s*\S+/im,
];
const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
const assertNoSecrets = (value: string) => {
if (secretPatterns.some((pattern) => pattern.test(value))) {
throw new Error("Sandbox input appears to contain a secret or token");
}
};
const normalizePath = (filePath: string) => {
const normalized = path.posix.normalize(
filePath.startsWith("/")
? filePath
: path.posix.join(sandboxLimits.workDir, filePath),
);
if (
normalized !== sandboxLimits.workDir &&
!normalized.startsWith(`${sandboxLimits.workDir}/`)
) {
throw new Error("Sandbox files must stay under /work");
}
if (path.posix.basename(normalized).toLowerCase() === ".env") {
throw new Error("Sandbox files cannot be named .env");
}
return normalized;
};
export const sanitizeSandboxFiles = (files: SandboxFile[] = []) => {
if (files.length > sandboxLimits.maxFiles) {
throw new Error(
`Sandbox input cannot exceed ${sandboxLimits.maxFiles} files`,
);
}
let bytes = 0;
const sanitized = files.map((file) => {
assertNoSecrets(file.path);
assertNoSecrets(file.content);
bytes += byteLength(file.content);
if (bytes > sandboxLimits.maxInputBytes) {
throw new Error("Sandbox input is too large");
}
return {
path: normalizePath(file.path),
content: file.content,
};
});
const seen = new Set<string>();
for (const file of sanitized) {
if (seen.has(file.path))
throw new Error(`Duplicate sandbox file: ${file.path}`);
seen.add(file.path);
}
return sanitized;
};
export const sanitizeReturnFiles = (returnFiles: string[] = []) =>
returnFiles.map(normalizePath);
export const assertSafeSandboxCommand = (command: string) => {
if (!command.trim()) throw new Error("Sandbox command cannot be empty");
assertNoSecrets(command);
return command.trim();
};
export const truncateText = (value: string, maxBytes: number) => {
if (byteLength(value) <= maxBytes) return value;
let output = "";
for (const char of value) {
if (byteLength(`${output}${char}`) > maxBytes) break;
output += char;
}
return `${output}\n[truncated]`;
};
export const truncateSandboxResult = (
result: SandboxRunResult,
): SandboxRunResult => ({
...result,
stdout: truncateText(result.stdout, sandboxLimits.maxOutputBytes),
stderr: truncateText(result.stderr, sandboxLimits.maxOutputBytes),
files: result.files.map((file) => ({
path: file.path,
content: truncateText(file.content, sandboxLimits.maxReturnedFileBytes),
})),
});

View File

@@ -0,0 +1,32 @@
export type SandboxFile = {
path: string;
content: string;
};
export type SandboxRunArgs = {
command: string;
files: SandboxFile[];
returnFiles: string[];
timeoutMs: number;
};
export type SandboxRunResult = {
stdout: string;
stderr: string;
exitCode?: number;
timedOut: boolean;
files: SandboxFile[];
};
export type SandboxSessionContext = {
channelId: string;
env: string;
orgId: string;
provider: string;
threadId: string;
workspaceId: string;
};
export type SandboxProvider = {
run(args: SandboxRunArgs): Promise<SandboxRunResult>;
};

View File

@@ -0,0 +1,24 @@
const labels: Record<string, string> = {
attach: "Attach plan",
updateSubscription: "Update subscription",
createSchedule: "Create schedule",
createBalance: "Create balance",
createPlan: "Create plan",
};
const previewWriteTools: Record<string, string> = {
previewAttach: "attach",
previewUpdateSubscription: "updateSubscription",
previewCreateSchedule: "createSchedule",
previewCreateBalance: "createBalance",
};
export const getWriteToolForPreview = (toolName: string) =>
previewWriteTools[toolName.replace(/^autumn_/, "")];
export const toolLabel = (toolName: string) =>
labels[toolName.replace(/^autumn_/, "")] ??
toolName
.replace(/^autumn_/, "")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase());

View File

@@ -0,0 +1,89 @@
const MAX_ERROR_MESSAGE_LENGTH = 700;
const cleanMessage = (message: string) =>
message
.replace(/^Error:\s*/, "")
.replace(/\s+/g, " ")
.trim();
const truncateMessage = (message: string) =>
message.length > MAX_ERROR_MESSAGE_LENGTH
? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 1)}`
: message;
const parseAutumnApiErrorMessage = (message: string) => {
const match = message.match(/Autumn API request failed \(\d+\):\s*(.+)$/s);
if (!match) return null;
try {
const parsed = JSON.parse(match[1] ?? "");
return typeof parsed?.message === "string" ? parsed.message : null;
} catch {
return null;
}
};
const getMcpContentText = (value: Record<string, unknown>): string | null => {
if (!Array.isArray(value.content)) return null;
const item = value.content.find((entry): entry is { text: string } =>
Boolean(
entry &&
typeof entry === "object" &&
"text" in entry &&
typeof entry.text === "string",
),
);
return item?.text ?? null;
};
const getObjectMessage = (value: Record<string, unknown>): string | null => {
if (typeof value.message === "string") return value.message;
if (typeof value.error === "string") return value.error;
if (
value.error &&
typeof value.error === "object" &&
typeof (value.error as { message?: unknown }).message === "string"
) {
return (value.error as { message: string }).message;
}
if (
value.details &&
typeof value.details === "object" &&
typeof (value.details as { errorMessage?: unknown }).errorMessage ===
"string"
) {
return (value.details as { errorMessage: string }).errorMessage;
}
const contentText = getMcpContentText(value);
if (!contentText) return null;
try {
const parsed = JSON.parse(contentText);
if (parsed && typeof parsed === "object") {
return getObjectMessage(parsed as Record<string, unknown>) ?? contentText;
}
} catch {
return contentText;
}
return null;
};
export const approvalErrorResult = (error: unknown) => {
const rawMessage =
error instanceof Error
? error.message
: typeof error === "string"
? error
: error && typeof error === "object"
? (getObjectMessage(error as Record<string, unknown>) ??
"The action failed.")
: "The action failed.";
const cleanedRawMessage = cleanMessage(rawMessage);
const message = cleanMessage(
parseAutumnApiErrorMessage(cleanedRawMessage) ?? cleanedRawMessage,
);
return {
error: true,
message: truncateMessage(message || "The action failed."),
};
};

View File

@@ -0,0 +1,204 @@
import type { AutumnLogger } from "@autumn/logging";
import type { ChatApproval, ChatInstallation } from "@autumn/shared";
import type { ActionEvent } from "chat";
import { toolLabel } from "../agent/toolPolicy.js";
import { logger as rootLogger } from "../lib/logger.js";
import type { AgentOutput } from "../types.js";
import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
import {
finishLoading,
type LoadingState,
type ReplyTarget,
} from "../ui/progress.js";
import { approvalErrorResult } from "./errors.js";
import { approvalRequestFromOutput } from "./request.js";
import {
approveAndRun,
cancelApproval,
createApproval,
getApproval,
isErrorResult,
} from "./store.js";
export const postApprovalRequest = async ({
channelId,
installation,
loading,
logAction,
logger = rootLogger,
output,
providerUserId,
target,
}: {
channelId: string;
installation: ChatInstallation;
loading: LoadingState;
logAction: (message: string) => Promise<void> | void;
logger?: AutumnLogger;
output: AgentOutput;
providerUserId: string;
target: ReplyTarget;
}) => {
const approval = approvalRequestFromOutput(output);
if (!approval) return false;
const approvalId = await createApproval({
orgId: installation.org_id,
provider: installation.provider,
workspaceId: installation.workspace_id,
channelId,
providerUserId,
...approval,
});
await logAction(`Waiting for approval: ${toolLabel(approval.toolName)}`);
logger.info("Created approval request", {
event: "leaf.approval_created",
context: {
env: approval.env,
org_id: installation.org_id,
},
approval_id: approvalId,
tool: approval.toolName,
});
await finishLoading(target, loading, "Preview ready.");
await target.post(
approvalCard({
id: approvalId,
env: approval.env,
toolName: approval.toolName,
toolArgs: approval.toolArgs,
preview: approval.preview,
}),
);
return true;
};
const detailsFromApproval = (approval?: ChatApproval) => ({
toolName: approval?.tool_name ?? "billing action",
toolArgs:
approval?.tool_args && typeof approval.tool_args === "object"
? (approval.tool_args as Record<string, unknown>)
: undefined,
preview: approval?.preview,
env: approval?.env,
});
const editActionMessage = async (
event: ActionEvent,
content: Parameters<NonNullable<ActionEvent["adapter"]["editMessage"]>>[2],
) => {
await event.adapter.editMessage?.(event.threadId, event.messageId, content);
};
type ApprovalActionDeps = {
approveAndRun: typeof approveAndRun;
cancelApproval: typeof cancelApproval;
editActionMessage: typeof editActionMessage;
getApproval: typeof getApproval;
logger: Pick<AutumnLogger, "error" | "info" | "warn">;
};
const defaultApprovalActionDeps = {
approveAndRun,
cancelApproval,
editActionMessage,
getApproval,
logger: rootLogger,
} satisfies ApprovalActionDeps;
const cardStatusForApproval = (
status?: string,
): "approved" | "cancelled" | "failed" | "running" => {
if (status === "approved" || status === "cancelled" || status === "running")
return status;
return "failed";
};
export const handleApprovalActionWithDeps = async (
event: ActionEvent,
deps: ApprovalActionDeps = defaultApprovalActionDeps,
) => {
if (!event.value) return;
try {
deps.logger.info("Received approval action", {
event: "leaf.approval_action_received",
approval_id: event.value,
action: event.actionId,
data: {
provider_user_id: event.user.userId,
},
});
const details = detailsFromApproval(await deps.getApproval(event.value));
if (event.actionId === "cancel_billing_action") {
const cancelled = await deps.cancelApproval(
event.value,
event.user.userId,
);
if (!cancelled) {
deps.logger.warn("Approval cancellation ignored", {
event: "leaf.approval_cancel_ignored",
approval_id: event.value,
});
const current = await deps.getApproval(event.value);
await deps.editActionMessage(
event,
approvalStatusCard({
status: cardStatusForApproval(current?.status),
...details,
}),
);
return;
}
await deps.editActionMessage(
event,
approvalStatusCard({ status: "cancelled", ...details }),
);
deps.logger.info("Cancelled approval", {
event: "leaf.approval_cancelled",
approval_id: event.value,
tool: details.toolName,
});
return;
}
await deps.editActionMessage(
event,
approvalStatusCard({ status: "running", ...details }),
);
const result = await deps.approveAndRun(event.value, event.user.userId);
deps.logger.info("Completed approval action", {
event: "leaf.approval_completed",
approval_id: event.value,
status: isErrorResult(result) ? "failed" : "approved",
tool: details.toolName,
});
await deps.editActionMessage(
event,
approvalStatusCard({
status: isErrorResult(result) ? "failed" : "approved",
...details,
result,
}),
);
} catch (error) {
deps.logger.error("[chat] Approval action failed", error, {
event: "leaf.approval_failed",
approval_id: event.value,
action: event.actionId,
});
const current = await deps.getApproval(event.value);
await deps.editActionMessage(
event,
approvalStatusCard({
status: cardStatusForApproval(current?.status),
...detailsFromApproval(current),
result: approvalErrorResult(error),
}),
);
}
};
export const handleApprovalAction = async (event: ActionEvent) =>
handleApprovalActionWithDeps(event);

View File

@@ -0,0 +1,26 @@
import type { AgentOutput } from "../types.js";
export const approvalRequestFromOutput = (output: AgentOutput) => {
if (output.finishReason === "suspended" && output.suspendPayload) {
return {
env: output.env,
runId: output.runId,
toolCallId: output.suspendPayload.toolCallId,
toolName: output.suspendPayload.toolName,
toolArgs: output.suspendPayload.args ?? {},
preview:
output.text ||
output.previewApproval?.preview ||
output.suspendPayload.args,
};
}
if (output.previewApproval) {
return {
env: output.env,
toolName: output.previewApproval.toolName,
toolArgs: output.previewApproval.toolArgs,
preview: output.text || output.previewApproval.preview,
};
}
};

View File

@@ -0,0 +1,165 @@
import crypto from "node:crypto";
import {
type AppEnv,
type ChatProvider,
chatApprovals,
chatInstallations,
} from "@autumn/shared";
import { addMinutes, isPast } from "date-fns";
import { and, eq, gt } from "drizzle-orm";
import { executeAutumnMcpTool } from "../agent/mcp.js";
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
import { db } from "../lib/db.js";
import { approvalErrorResult } from "./errors.js";
export const normalizeToolName = (toolName: string) =>
toolName.replace(/^autumn_/, "");
export const isErrorResult = (result: unknown): boolean =>
typeof result === "object" &&
result !== null &&
("error" in result ||
(result as { isError?: unknown }).isError === true ||
(result as { id?: unknown }).id === "TOOL_EXECUTION_FAILED" ||
(typeof (result as { code?: unknown }).code === "string" &&
typeof (result as { message?: unknown }).message === "string"));
export const createApproval = async ({
orgId,
provider,
workspaceId,
channelId,
providerUserId,
env,
runId,
toolCallId,
toolName,
toolArgs,
preview,
}: {
orgId: string;
provider: ChatProvider;
workspaceId: string;
channelId: string;
providerUserId: string;
env: AppEnv;
runId?: string;
toolCallId?: string;
toolName: string;
toolArgs: Record<string, unknown>;
preview?: unknown;
}) => {
const id = `chat_app_${crypto.randomUUID().replace(/-/g, "")}`;
await db.insert(chatApprovals).values({
id,
org_id: orgId,
provider,
workspace_id: workspaceId,
channel_id: channelId,
provider_user_id: providerUserId,
env,
run_id: runId,
tool_call_id: toolCallId,
tool_name: normalizeToolName(toolName),
tool_args: toolArgs,
preview,
status: "pending",
created_at: Date.now(),
expires_at: addMinutes(Date.now(), 15).getTime(),
});
return id;
};
export const cancelApproval = async (id: string, providerUserId: string) => {
const [claimed] = await db
.update(chatApprovals)
.set({
status: "cancelled",
decided_at: Date.now(),
decided_by_provider_user_id: providerUserId,
})
.where(and(eq(chatApprovals.id, id), eq(chatApprovals.status, "pending")))
.returning();
return claimed;
};
export const getApproval = async (id: string) =>
await db.query.chatApprovals.findFirst({
where: eq(chatApprovals.id, id),
});
export const approveAndRun = async (id: string, providerUserId: string) => {
const approval = await db.query.chatApprovals.findFirst({
where: eq(chatApprovals.id, id),
});
if (
!approval ||
approval.status !== "pending" ||
isPast(approval.expires_at)
) {
throw new Error("Approval is no longer pending");
}
const [claimed] = await db
.update(chatApprovals)
.set({
status: "running",
decided_at: Date.now(),
decided_by_provider_user_id: providerUserId,
})
.where(
and(
eq(chatApprovals.id, id),
eq(chatApprovals.status, "pending"),
gt(chatApprovals.expires_at, Date.now()),
),
)
.returning();
if (!claimed) throw new Error("Approval is no longer pending");
try {
const installation = await db.query.chatInstallations.findFirst({
where: and(
eq(chatInstallations.org_id, claimed.org_id),
eq(chatInstallations.provider, claimed.provider),
eq(chatInstallations.workspace_id, claimed.workspace_id),
),
});
if (!installation) throw new Error("Chat installation not found");
const token = await getInstallationOAuthAccessToken({
installation,
env: claimed.env,
});
const rawResult = await executeAutumnMcpTool({
token,
env: claimed.env,
toolName: claimed.tool_name,
args: claimed.tool_args,
});
const result = isErrorResult(rawResult)
? approvalErrorResult(rawResult)
: rawResult;
await db
.update(chatApprovals)
.set({
status: isErrorResult(result) ? "failed" : "approved",
decided_at: Date.now(),
decided_by_provider_user_id: providerUserId,
})
.where(eq(chatApprovals.id, id));
return result;
} catch (error) {
const result = approvalErrorResult(error);
await db
.update(chatApprovals)
.set({
status: "failed",
decided_at: Date.now(),
decided_by_provider_user_id: providerUserId,
})
.where(eq(chatApprovals.id, id));
return result;
}
};

228
apps/leaf/src/bot.ts Normal file
View File

@@ -0,0 +1,228 @@
import { createSlackAdapter } from "@chat-adapter/slack";
import { createPostgresState } from "@chat-adapter/state-pg";
import type { Attachment, Message, Thread } from "chat";
import { Chat } from "chat";
import { runMessage } from "./agent/messages.js";
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
import { decrypt } from "./lib/crypto.js";
import { env } from "./lib/env.js";
import {
addLeafContext,
createLeafSessionContext,
logger as rootLogger,
} from "./lib/logger.js";
import { getSlackWorkspaceId } from "./providers/slack/context.js";
import {
fetchSlackAttachmentFallback,
getSlackFilesFromRaw,
} from "./providers/slack/files.js";
import { findInstallationWithOrg } from "./providers/slack/installations.js";
import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js";
import {
createActionLogger,
finishLoading,
type LoadingState,
type ReplyTarget,
startLoading,
} from "./ui/progress.js";
export const chatAdapterNames = ["slack"];
const getSlackAdminProvider = () =>
`slack_admin:${env.SLACK_CLIENT_ID}` as const;
const findSlackInstallationForWorkspace = async ({
workspaceId,
}: {
workspaceId: string;
}) => {
return (
(await findInstallationWithOrg(getSlackAdminProvider(), workspaceId)) ??
(await findInstallationWithOrg("slack", workspaceId))
);
};
export const bot = new Chat({
userName: env.CHAT_NAME,
adapters: {
slack: createSlackAdapter({
clientId: env.SLACK_CLIENT_ID,
clientSecret: env.SLACK_CLIENT_SECRET,
installationProvider: {
getInstallation: async (workspaceId) => {
const installation = await findSlackInstallationForWorkspace({
workspaceId,
});
if (!installation) return null;
return {
botToken: decrypt(installation.bot_access_token),
botUserId: installation.bot_user_id ?? undefined,
teamName: installation.workspace_name,
};
},
},
signingSecret: env.SLACK_SIGNING_SECRET,
userName: env.CHAT_NAME,
}),
},
state: createPostgresState({
keyPrefix: "chat",
url: env.CHAT_STATE_DATABASE_URL,
}),
concurrency: "queue",
});
const runAndReply = async ({
channelId,
attachments,
providerUserId,
raw,
recentMessages,
target,
text,
threadId,
}: {
attachments?: Attachment[];
channelId: string;
providerUserId: string;
raw: unknown;
recentMessages?: ChatContextMessage[];
target: ReplyTarget;
text: string;
threadId: string;
}) => {
let loading: LoadingState = null;
let logger = rootLogger;
try {
const workspaceId = getSlackWorkspaceId(raw);
const installation = await findSlackInstallationForWorkspace({
workspaceId,
});
if (!installation) {
logger.warn("Slack installation not found", {
event: "leaf.slack_installation_missing",
});
return;
}
const session = createLeafSessionContext({
channelId,
provider: installation.provider,
providerUserId,
threadId,
workspaceId,
});
logger = addLeafContext(rootLogger, {
...session.context,
agent_run_id: session.agentRunId,
org_id: installation.org_id,
org_slug: installation.org_slug,
});
logger.info("Received Slack message", {
event: "leaf.slack_message_received",
data: {
attachment_count: attachments?.length ?? 0,
text_length: text.length,
},
});
if (!text.trim() && !attachments?.length) {
logger.info("Skipping empty Slack message", {
event: "leaf.slack_message_skipped",
data: { reason: "empty" },
});
return;
}
loading = await startLoading(target);
const logAction = createActionLogger(loading);
const rawFiles = getSlackFilesFromRaw({ raw });
const botToken = decrypt(installation.bot_access_token);
const output = await runMessage({
agentRunId: session.agentRunId,
attachmentFetchFallback: ({ attachment }) =>
fetchSlackAttachmentFallback({
attachment,
botToken,
rawFiles,
}),
attachments,
installation,
logger,
onAction: logAction,
recentMessages,
text,
channelId,
threadId,
});
const postedApproval = await postApprovalRequest({
channelId,
installation,
loading,
logAction,
logger,
output,
providerUserId,
target,
});
if (postedApproval) return;
await finishLoading(target, loading, "Done.");
await target.post({ markdown: output.text || "Done." });
logger.info("Posted Slack response", {
event: "leaf.slack_response_posted",
data: {
has_text: Boolean(output.text),
},
});
} catch (error) {
logger.error("[chat] Message failed", error, {
event: "leaf.slack_message_failed",
});
await finishLoading(target, loading, "Request failed.");
await target.post({
markdown: "I could not complete that request. Please try again.",
});
}
};
const handleMessage = async (thread: Thread, message: Message) => {
await runAndReply({
target: thread,
attachments: message.attachments,
raw: message.raw,
text: message.text,
channelId: thread.channelId,
providerUserId: message.author.userId,
threadId: thread.id,
recentMessages: await getRecentMessages(thread, message),
});
};
bot.onDirectMessage(handleMessage);
bot.onNewMention(async (thread, message) => {
await thread.subscribe();
await handleMessage(thread, message);
});
bot.onSubscribedMessage(handleMessage);
bot.onSlashCommand(async (event) => {
await runAndReply({
target: event.channel,
raw: event.raw,
text: event.text || event.command,
channelId: event.channel.id,
providerUserId: event.user.userId,
threadId: event.channel.id,
});
});
bot.onAction(
["approve_billing_action", "cancel_billing_action"],
handleApprovalAction,
);
bot.registerSingleton();

4
apps/leaf/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { initInfisical } from "@autumn/shared/utils/infisical";
await initInfisical();
await import("./main.js");

View File

@@ -0,0 +1,103 @@
import type { AppEnv, ChatInstallation } from "@autumn/shared";
import { decrypt, encrypt } from "../../../lib/crypto.js";
import { db } from "../../../lib/db.js";
import { env as leafEnv } from "../../../lib/env.js";
import {
getChatOAuthCredentialByInstallationEnv,
updateChatOAuthCredentialTokens,
} from "../repos/chatOAuthCredentialsRepo.js";
import {
parseOAuthScopeString,
parseOAuthTokenResponse,
} from "../utils/oauthTokenResponse.js";
import { replaceInstallationOAuthCredentials } from "./replaceInstallationOAuthCredentials.js";
const TOKEN_EXPIRY_SKEW_MS = 60_000;
const getTokenEndpoint = () =>
new URL("/api/auth/oauth2/token", leafEnv.BETTER_AUTH_URL).href;
const getDefaultExpiresAt = () => Date.now() + 60 * 60 * 1000;
export const getInstallationOAuthAccessToken = async ({
installation,
env,
}: {
installation: ChatInstallation;
env: AppEnv;
}) => {
let credential = await getChatOAuthCredentialByInstallationEnv({
db,
chatInstallationId: installation.id,
env,
});
if (
installation.provider.startsWith("slack_admin") &&
(!credential || credential.org_id !== installation.org_id)
) {
await db.transaction(async (tx) => {
await replaceInstallationOAuthCredentials({
tx,
installation,
userId: installation.installed_by_user_id ?? "",
});
});
credential = await getChatOAuthCredentialByInstallationEnv({
db,
chatInstallationId: installation.id,
env,
});
}
if (!credential) {
throw new Error(
`Missing ${env} Autumn OAuth credentials for Slack install`,
);
}
if (credential.access_token_expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now()) {
return decrypt(credential.access_token);
}
const refreshToken = decrypt(credential.refresh_token);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: credential.oauth_client_id,
});
const response = await fetch(getTokenEndpoint(), {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
throw new Error(
`Could not refresh ${env} Autumn OAuth token for Slack install`,
);
}
const parsed = parseOAuthTokenResponse({ body: await response.json() });
const accessTokenExpiresAt = parsed.expires_in
? Date.now() + parsed.expires_in * 1000
: getDefaultExpiresAt();
const nextRefreshToken = parsed.refresh_token ?? refreshToken;
const scopes = parseOAuthScopeString({ scope: parsed.scope });
await updateChatOAuthCredentialTokens({
db,
id: credential.id,
accessToken: encrypt(parsed.access_token),
refreshToken: encrypt(nextRefreshToken),
accessTokenExpiresAt,
scopes: scopes.length > 0 ? scopes : credential.scopes,
updatedAt: Date.now(),
});
return parsed.access_token;
};

View File

@@ -0,0 +1,313 @@
import crypto from "node:crypto";
import { prefixOAuthToken } from "@autumn/auth";
import {
AppEnv,
type ChatInstallation,
chatOAuthCredentials,
LEAF_OAUTH_SCOPES,
oauthAccessToken,
oauthClient,
oauthConsent,
oauthRefreshToken,
} from "@autumn/shared";
import { and, eq, sql } from "drizzle-orm";
import { encrypt } from "../../../lib/crypto.js";
import type { db } from "../../../lib/db.js";
import {
AUTUMN_ADMIN_OAUTH_CLIENT_ID,
AUTUMN_SLACK_OAUTH_CLIENT_ID,
} from "./upsertInstallationOAuthCredential.js";
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
const REFRESH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const SLACK_ADMIN_CONSENT_KIND = "slack_admin";
type OAuthConsentMetadata =
| {
kind: typeof SLACK_ADMIN_CONSENT_KIND;
chatInstallationId: string;
createdByUserId: string;
}
| Record<string, never>;
const isSlackAdminInstallation = ({
installation,
}: {
installation: ChatInstallation;
}) => installation.provider.startsWith("slack_admin");
const getSlackMcpOAuthClientId = ({
installation,
}: {
installation: ChatInstallation;
}) =>
isSlackAdminInstallation({ installation })
? AUTUMN_ADMIN_OAUTH_CLIENT_ID
: AUTUMN_SLACK_OAUTH_CLIENT_ID;
const getSlackMcpOAuthClientName = ({
installation,
}: {
installation: ChatInstallation;
}) => (isSlackAdminInstallation({ installation }) ? "Slack Admin" : "Slack");
const getOAuthClientMetadata = ({
installation,
}: {
installation: ChatInstallation;
}) => ({
kind: "mcp_client",
mcpClientType: isSlackAdminInstallation({ installation })
? "slack_admin"
: "slack",
});
const getOAuthConsentMetadata = ({
installation,
userId,
}: {
installation: ChatInstallation;
userId: string;
}): OAuthConsentMetadata =>
isSlackAdminInstallation({ installation })
? {
kind: SLACK_ADMIN_CONSENT_KIND,
chatInstallationId: installation.id,
createdByUserId: userId,
}
: {};
const tokenHash = ({ token }: { token: string }) => {
const hash = crypto.createHash("sha256").update(token).digest();
return hash
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
const generateToken = () => crypto.randomBytes(48).toString("base64url");
const ensureSlackMcpOAuthClient = async ({
tx,
installation,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
}) => {
const now = new Date();
const clientId = getSlackMcpOAuthClientId({ installation });
const name = getSlackMcpOAuthClientName({ installation });
const metadata = getOAuthClientMetadata({ installation });
await tx
.insert(oauthClient)
.values({
id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`,
clientId,
name,
redirectUris: ["slack://autumn-chat"],
scopes: [...LEAF_OAUTH_SCOPES],
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
public: true,
type: "native",
metadata,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: oauthClient.clientId,
set: {
name,
scopes: [...LEAF_OAUTH_SCOPES],
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
public: true,
type: "native",
metadata,
updatedAt: now,
},
});
};
const upsertOAuthConsent = async ({
tx,
env,
orgId,
userId,
clientId,
metadata,
}: {
tx: ChatTransaction;
env: AppEnv;
orgId: string;
userId: string;
clientId: string;
metadata: OAuthConsentMetadata;
}) => {
const now = new Date();
const [existingConsent] = await tx
.select({ id: oauthConsent.id })
.from(oauthConsent)
.where(
and(
eq(oauthConsent.clientId, clientId),
eq(oauthConsent.userId, userId),
eq(oauthConsent.referenceId, orgId),
eq(oauthConsent.env, env),
metadata?.kind === SLACK_ADMIN_CONSENT_KIND
? sql`${oauthConsent.metadata}->>'kind' = ${SLACK_ADMIN_CONSENT_KIND}`
: sql`COALESCE(${oauthConsent.metadata}->>'kind', '') != ${SLACK_ADMIN_CONSENT_KIND}`,
),
)
.limit(1);
if (existingConsent) {
await tx
.update(oauthConsent)
.set({
scopes: [...LEAF_OAUTH_SCOPES],
metadata,
updatedAt: now,
})
.where(eq(oauthConsent.id, existingConsent.id));
return existingConsent.id;
}
const consentId = `oauth_consent_${crypto.randomUUID().replace(/-/g, "")}`;
await tx.insert(oauthConsent).values({
id: consentId,
clientId,
userId,
referenceId: orgId,
scopes: [...LEAF_OAUTH_SCOPES],
env,
redirectUri: "slack://autumn-chat",
metadata,
createdAt: now,
updatedAt: now,
});
return consentId;
};
const createCredentialForEnv = async ({
tx,
installation,
env,
userId,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
env: AppEnv;
userId: string;
}) => {
const now = Date.now();
const nowDate = new Date(now);
const rawAccessToken = generateToken();
const rawRefreshToken = generateToken();
const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS;
const refreshTokenExpiresAt = now + REFRESH_TOKEN_TTL_MS;
const refreshTokenId = `oauth_refresh_${crypto.randomUUID().replace(/-/g, "")}`;
const accessTokenId = `oauth_access_${crypto.randomUUID().replace(/-/g, "")}`;
const clientId = getSlackMcpOAuthClientId({ installation });
const metadata = getOAuthConsentMetadata({ installation, userId });
const consentId = await upsertOAuthConsent({
tx,
env,
orgId: installation.org_id,
userId,
clientId,
metadata,
});
await tx.insert(oauthRefreshToken).values({
id: refreshTokenId,
token: tokenHash({ token: rawRefreshToken }),
clientId,
userId,
referenceId: installation.org_id,
expiresAt: new Date(refreshTokenExpiresAt),
createdAt: nowDate,
authTime: nowDate,
scopes: [...LEAF_OAUTH_SCOPES],
});
await tx.insert(oauthAccessToken).values({
id: accessTokenId,
token: tokenHash({ token: rawAccessToken }),
clientId,
userId,
referenceId: installation.org_id,
refreshId: refreshTokenId,
expiresAt: new Date(accessTokenExpiresAt),
createdAt: nowDate,
scopes: [...LEAF_OAUTH_SCOPES],
});
const credential = {
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
chat_installation_id: installation.id,
org_id: installation.org_id,
env,
oauth_client_id: clientId,
oauth_consent_id: consentId,
access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })),
refresh_token: encrypt(rawRefreshToken),
access_token_expires_at: accessTokenExpiresAt,
scopes: [...LEAF_OAUTH_SCOPES],
created_at: now,
updated_at: now,
};
await tx
.insert(chatOAuthCredentials)
.values(credential)
.onConflictDoUpdate({
target: [
chatOAuthCredentials.chat_installation_id,
chatOAuthCredentials.env,
],
set: {
org_id: credential.org_id,
oauth_client_id: credential.oauth_client_id,
oauth_consent_id: credential.oauth_consent_id,
access_token: credential.access_token,
refresh_token: credential.refresh_token,
access_token_expires_at: credential.access_token_expires_at,
scopes: credential.scopes,
updated_at: credential.updated_at,
},
});
};
export const replaceInstallationOAuthCredentials = async ({
tx,
installation,
userId,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
userId: string;
}) => {
if (!userId) {
throw new Error("Missing user id for Slack MCP OAuth credentials");
}
await ensureSlackMcpOAuthClient({ tx, installation });
await createCredentialForEnv({
tx,
installation,
env: AppEnv.Sandbox,
userId,
});
await createCredentialForEnv({
tx,
installation,
env: AppEnv.Live,
userId,
});
};

View File

@@ -0,0 +1,48 @@
import crypto from "node:crypto";
import type { AppEnv, ChatInstallation } from "@autumn/shared";
import { encrypt } from "../../../lib/crypto.js";
import { db } from "../../../lib/db.js";
import { upsertChatOAuthCredential } from "../repos/chatOAuthCredentialsRepo.js";
export const AUTUMN_SLACK_OAUTH_CLIENT_ID = "autumn_mcp_slack";
export const AUTUMN_ADMIN_OAUTH_CLIENT_ID = "autumn_admin";
export const upsertInstallationOAuthCredential = async ({
installation,
env,
accessToken,
refreshToken,
accessTokenExpiresAt,
scopes,
oauthClientId = AUTUMN_SLACK_OAUTH_CLIENT_ID,
oauthConsentId,
}: {
installation: ChatInstallation;
env: AppEnv;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
scopes: string[];
oauthClientId?: string;
oauthConsentId?: string | null;
}) => {
const now = Date.now();
return upsertChatOAuthCredential({
db,
credential: {
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
chat_installation_id: installation.id,
org_id: installation.org_id,
env,
oauth_client_id: oauthClientId,
oauth_consent_id: oauthConsentId ?? null,
access_token: encrypt(accessToken),
refresh_token: encrypt(refreshToken),
access_token_expires_at: accessTokenExpiresAt,
scopes,
created_at: now,
updated_at: now,
},
});
};

View File

@@ -0,0 +1,89 @@
import {
type AppEnv,
type ChatOAuthCredential,
chatOAuthCredentials,
} from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import type { ChatDb } from "../../../lib/db.js";
export type ChatOAuthCredentialInsert =
typeof chatOAuthCredentials.$inferInsert;
export const getChatOAuthCredentialByInstallationEnv = async ({
db,
chatInstallationId,
env,
}: {
db: ChatDb;
chatInstallationId: string;
env: AppEnv;
}) =>
db.query.chatOAuthCredentials.findFirst({
where: and(
eq(chatOAuthCredentials.chat_installation_id, chatInstallationId),
eq(chatOAuthCredentials.env, env),
),
});
export const upsertChatOAuthCredential = async ({
db,
credential,
}: {
db: ChatDb;
credential: ChatOAuthCredentialInsert;
}) => {
const [row] = await db
.insert(chatOAuthCredentials)
.values(credential)
.onConflictDoUpdate({
target: [
chatOAuthCredentials.chat_installation_id,
chatOAuthCredentials.env,
],
set: {
org_id: credential.org_id,
oauth_client_id: credential.oauth_client_id,
oauth_consent_id: credential.oauth_consent_id,
access_token: credential.access_token,
refresh_token: credential.refresh_token,
access_token_expires_at: credential.access_token_expires_at,
scopes: credential.scopes,
updated_at: credential.updated_at,
},
})
.returning();
return row as ChatOAuthCredential;
};
export const updateChatOAuthCredentialTokens = async ({
db,
id,
accessToken,
refreshToken,
accessTokenExpiresAt,
scopes,
updatedAt,
}: {
db: ChatDb;
id: string;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
scopes: string[];
updatedAt: number;
}) => {
const [row] = await db
.update(chatOAuthCredentials)
.set({
access_token: accessToken,
refresh_token: refreshToken,
access_token_expires_at: accessTokenExpiresAt,
scopes,
updated_at: updatedAt,
})
.where(eq(chatOAuthCredentials.id, id))
.returning();
return row as ChatOAuthCredential | undefined;
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
const oauthTokenPayloadSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1).optional(),
expires_in: z.number().optional(),
scope: z.string().optional(),
});
const oauthTokenResponseSchema = z.preprocess((value) => {
if (value && typeof value === "object" && "response" in value) {
return (value as { response?: unknown }).response;
}
return value;
}, oauthTokenPayloadSchema);
export const parseOAuthTokenResponse = ({ body }: { body: unknown }) =>
oauthTokenResponseSchema.parse(body);
export const parseOAuthScopeString = ({ scope }: { scope?: string }) =>
scope?.split(/\s+/).filter(Boolean) ?? [];

View File

@@ -0,0 +1,49 @@
import type { AppEnv } from "@autumn/shared";
import type { TracingOptions } from "@mastra/core/observability";
const compact = (values: Array<string | null | undefined>) =>
values.filter((value): value is string => Boolean(value));
export const createLeafTracingOptions = ({
agentRunId,
channelId,
env,
orgId,
orgSlug,
provider,
source,
threadId,
workspaceId,
setup,
}: {
agentRunId?: string;
channelId?: string;
env?: AppEnv | string;
orgId?: string;
orgSlug?: string | null;
provider?: string;
source: "eval" | "prod";
threadId?: string;
workspaceId?: string;
setup?: string;
}): TracingOptions => ({
metadata: {
agent_run_id: agentRunId,
autumn_env: env,
org_id: orgId,
org_slug: orgSlug,
provider,
setup,
slack_channel_id: channelId,
slack_thread_id: threadId,
slack_workspace_id: workspaceId,
source,
},
tags: compact([
source,
env ? `autumn:${env}` : undefined,
orgSlug ? `org:${orgSlug}` : undefined,
provider ? `provider:${provider}` : undefined,
setup ? `setup:${setup}` : undefined,
]),
});

View File

@@ -0,0 +1,37 @@
import crypto from "node:crypto";
import { env } from "./env.js";
const key = () =>
crypto.createHash("sha256").update(env.ENCRYPTION_PASSWORD).digest();
export const encrypt = (data: string) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key(), iv);
const ciphertext = Buffer.concat([
cipher.update(data, "utf8"),
cipher.final(),
]);
return Buffer.concat([
Buffer.from([1]),
iv,
cipher.getAuthTag(),
ciphertext,
]).toString("base64");
};
export const decrypt = (data: string) => {
const buffer = Buffer.from(data, "base64");
if (buffer[0] !== 1) throw new Error("Unsupported encrypted payload");
const iv = buffer.subarray(1, 13);
const authTag = buffer.subarray(13, 29);
const ciphertext = buffer.subarray(29);
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
key(),
iv,
);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
"utf8",
);
};

9
apps/leaf/src/lib/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import * as schema from "@autumn/shared/db/schema";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "./env.js";
const client = postgres(env.DATABASE_URL, { max: 4 });
export const db = drizzle(client, { schema });
export type ChatDb = typeof db;

53
apps/leaf/src/lib/env.ts Normal file
View File

@@ -0,0 +1,53 @@
import { z } from "zod";
const optionalString = z.preprocess(
(value) => (value === "" ? undefined : value),
z.string().min(1).optional(),
);
const envSchema = z
.object({
MCP_SERVER_URL: optionalString,
BETTER_AUTH_SECRET: optionalString,
BETTER_AUTH_URL: optionalString,
CHAT_MODEL: z.string().min(1).default("anthropic/claude-sonnet-4-6"),
CHAT_NAME: z.string().min(1).default("Autumn"),
CHAT_STATE_DATABASE_URL: optionalString,
CHAT_STATE_SECRET: optionalString,
CLIENT_URL: z.string().min(1).default("http://localhost:3000"),
DATABASE_URL: z.string().min(1),
E2B_API_KEY: optionalString,
ENCRYPTION_PASSWORD: z.string().min(1),
FIRECRAWL_API_KEY: z.string().min(1),
MCP_OAUTH_ENVIRONMENT: z.enum(["live", "sandbox"]).default("sandbox"),
PORT: z.coerce.number().int().positive().default(3099),
SLACK_CLIENT_ID: z.string().min(1),
SLACK_CLIENT_SECRET: z.string().min(1),
SLACK_REDIRECT_URI: optionalString,
SLACK_SIGNING_SECRET: z.string().min(1),
SLACK_STATE_SECRET: optionalString,
})
.transform((values) => {
const databaseUrl = new URL(values.DATABASE_URL);
databaseUrl.pathname = "/chat";
return {
...values,
MCP_SERVER_URL:
values.MCP_SERVER_URL ?? `http://localhost:${values.PORT}`,
BETTER_AUTH_URL:
values.BETTER_AUTH_URL ??
(process.env.NODE_ENV === "production"
? "https://api.useautumn.com"
: "http://localhost:8080"),
CHAT_STATE_DATABASE_URL:
values.CHAT_STATE_DATABASE_URL ?? databaseUrl.toString(),
CHAT_STATE_SECRET:
values.CHAT_STATE_SECRET ??
values.SLACK_STATE_SECRET ??
values.BETTER_AUTH_SECRET ??
values.ENCRYPTION_PASSWORD,
};
});
export const env = envSchema.parse(process.env);

View File

@@ -0,0 +1,60 @@
import {
type AutumnLogger,
createAppLogger,
createSessionId,
createTraceId,
} from "@autumn/logging";
export const logger = createAppLogger({
service: "leaf",
dataset: process.env.LEAF_LOG_DATASET ?? "leaf",
preset: "default",
});
export const createLeafSessionContext = ({
channelId,
provider,
providerUserId,
threadId,
workspaceId,
}: {
channelId: string;
provider: string;
providerUserId: string;
threadId: string;
workspaceId: string;
}) => {
const traceId = createTraceId();
const sessionId = createSessionId({
parts: {
channelId,
provider,
threadId,
workspaceId,
},
});
return {
agentRunId: createTraceId(),
sessionId,
traceId,
context: {
provider,
provider_user_id: providerUserId,
session_id: sessionId,
trace_id: traceId,
slack_channel_id: channelId,
slack_thread_id: threadId,
slack_workspace_id: workspaceId,
},
};
};
export const addLeafContext = (
baseLogger: AutumnLogger,
context: Record<string, unknown>,
): AutumnLogger =>
baseLogger.child({
context: {
context,
},
});

49
apps/leaf/src/main.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { HttpBindings } from "@hono/node-server";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { chatAdapterNames } from "./bot.js";
import { env } from "./lib/env.js";
import { logger } from "./lib/logger.js";
import { createMcpRouter } from "./mcp/mcpRouter.js";
import { slackRoutes } from "./providers/slack/routes.js";
const app = new Hono<{ Bindings: HttpBindings }>();
app.use("*", async (c, next) => {
c.header("Access-Control-Allow-Origin", "*");
c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
c.header("Access-Control-Allow-Headers", "*");
return c.req.method === "OPTIONS" ? c.body(null, 204) : next();
});
app.get("/health", (c) => c.json({ ok: true }));
app.route(
"",
createMcpRouter({
"oauth-enabled": true,
"oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
"server-url": env.BETTER_AUTH_URL,
logger,
resourceUrl: new URL("/mcp", env.MCP_SERVER_URL).href,
}),
);
app.route("/slack", slackRoutes);
serve(
{
fetch: app.fetch,
hostname: "0.0.0.0",
port: env.PORT,
},
({ address, port }) => {
logger.info("Chat listening", {
event: "leaf.server_started",
data: {
host: `${address}:${port}`,
adapters: chatAdapterNames,
},
});
},
);

View File

@@ -0,0 +1,30 @@
import { getOAuthIssuerUrl } from "@autumn/auth/oauth";
import { DEFAULT_AUTUMN_API_URL } from "@autumn/mcp";
import { LEAF_OAUTH_SCOPES } from "@autumn/shared";
export class OAuthHttpError extends Error {
constructor(
readonly status: number,
message: string,
readonly error = "invalid_token",
readonly wwwAuthenticate?: string,
) {
super(message);
}
}
export const getProtectedResourceMetadata = ({
resourceUrl,
serverURL,
}: {
resourceUrl: string;
serverURL?: string;
}) => ({
resource: resourceUrl,
authorization_servers: [
getOAuthIssuerUrl({ baseUrl: serverURL ?? DEFAULT_AUTUMN_API_URL }),
],
scopes_supported: [...LEAF_OAUTH_SCOPES],
bearer_methods_supported: ["header"],
resource_name: "Autumn MCP",
});

View File

@@ -0,0 +1,184 @@
import { createHash } from "node:crypto";
import { getBearerToken, isOAuthToken, isSecretKeyPrefix } from "@autumn/auth";
import {
getProtectedResourceMetadataUrl,
getWwwAuthenticateHeader,
} from "@autumn/auth/oauth";
import {
type AutumnMcpAuth,
DEFAULT_API_VERSION,
environmentSchema,
type MCPServerFlags,
type OAuthEnvironment,
} from "@autumn/mcp";
import { LEAF_OAUTH_SCOPES } from "@autumn/shared";
import * as z from "zod/v4";
import { OAuthHttpError } from "./protectedResourceMetadata.js";
type AuthLogger = {
warning: (message: string, data?: Record<string, unknown>) => void;
};
export interface MCPOAuthFlags extends MCPServerFlags {
readonly "oauth-enabled"?: boolean | undefined;
readonly "oauth-environment"?: OAuthEnvironment | undefined;
}
const xApiVersionSchema = z.string().default(DEFAULT_API_VERSION);
const secretKeySchema = z.string().min(1).optional();
const failOpenSchema = z
.union([
z.boolean(),
z.enum(["true", "false"]).transform((v) => v === "true"),
])
.default(true);
const parseRequestOption = <T>({
value,
schema,
message,
}: {
value: unknown;
schema: z.ZodType<T>;
message: string;
}): T => {
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
throw new OAuthHttpError(400, message, "invalid_request");
};
const getEnvironment = ({
headers,
flags,
}: {
headers: Headers;
flags: MCPOAuthFlags;
}): OAuthEnvironment =>
parseRequestOption({
value:
headers.get("x-autumn-environment") ??
flags["oauth-environment"] ??
"sandbox",
schema: environmentSchema,
message: "Invalid x-autumn-environment",
});
const getStaticApiKey = ({
headers,
flags,
}: {
headers: Headers;
flags: MCPOAuthFlags;
}): string | undefined => {
const secretKey = headers.get("secret-key");
if (secretKey && isSecretKeyPrefix({ token: secretKey })) return secretKey;
const bearer = getBearerToken({ headers });
if (bearer && isSecretKeyPrefix({ token: bearer })) return bearer;
const fallbackSecretKey = flags["secret-key"];
if (
!flags["oauth-enabled"] &&
fallbackSecretKey &&
isSecretKeyPrefix({ token: fallbackSecretKey })
) {
return fallbackSecretKey;
}
return undefined;
};
const principalFromSecret = ({
kind,
value,
}: {
kind: string;
value: string;
}) => {
const digest = createHash("sha256").update(value).digest("hex").slice(0, 32);
return `${kind}:${digest}`;
};
export const buildAuthForRequest = async ({
headers,
flags,
logger,
resourceUrl,
}: {
headers: Headers;
flags: MCPOAuthFlags;
logger: AuthLogger;
resourceUrl: string;
}): Promise<AutumnMcpAuth> => {
const env = getEnvironment({ headers, flags });
const xApiVersion = parseRequestOption({
value: headers.get("x-api-version") ?? flags["x-api-version"],
schema: xApiVersionSchema,
message: "Invalid x-api-version",
});
const failOpen = parseRequestOption({
value: headers.get("fail-open") ?? flags["fail-open"],
schema: failOpenSchema,
message: "Invalid fail-open",
});
const apiKey = parseRequestOption({
value: getStaticApiKey({ headers, flags }),
schema: secretKeySchema,
message: "Invalid secret-key",
});
if (apiKey) {
return {
apiKey,
authMethod: "secret-key",
env,
resource: resourceUrl,
principalId: principalFromSecret({ kind: "secret-key", value: apiKey }),
scopes: [...LEAF_OAUTH_SCOPES],
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
const bearer = getBearerToken({ headers });
if (bearer && isOAuthToken({ token: bearer })) {
return {
apiKey: bearer,
authMethod: "oauth",
env,
resource: resourceUrl,
principalId: "oauth:unverified",
scopes: [...LEAF_OAUTH_SCOPES],
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
if (bearer) {
throw new OAuthHttpError(
401,
"Invalid OAuth token prefix",
"invalid_token",
);
}
if (flags["oauth-enabled"]) {
throw new OAuthHttpError(
401,
"Missing Autumn API key bearer token",
"invalid_token",
getWwwAuthenticateHeader({
resourceMetadataUrl: getProtectedResourceMetadataUrl({
resourceUrl,
}),
error: "invalid_token",
}),
);
}
logger.warning("Missing secret-key for MCP request");
throw new OAuthHttpError(401, "Missing secret-key", "invalid_token");
};

View File

@@ -0,0 +1,3 @@
export const MCP_PATH = "/mcp" as const;
export const PROTECTED_RESOURCE_METADATA_PATH =
"/.well-known/oauth-protected-resource/mcp";

View File

@@ -0,0 +1,71 @@
import { randomUUID } from "node:crypto";
import {
type createAutumnOperationsMCPServer,
} from "@autumn/mcp";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import {
buildAuthForRequest,
} from "../auth/resolveRequestAuth.js";
import { OAuthHttpError } from "../auth/protectedResourceMetadata.js";
import type { LeafMcpContext, McpRouteOptions } from "../types.js";
type McpServer = ReturnType<typeof createAutumnOperationsMCPServer>;
type McpAuth = Awaited<ReturnType<typeof buildAuthForRequest>>;
const setIncomingAuth = ({
c,
auth,
}: {
c: LeafMcpContext;
auth: McpAuth;
}) => {
(c.env.incoming as typeof c.env.incoming & { auth?: McpAuth }).auth = auth;
};
const oauthErrorResponse = (c: LeafMcpContext, error: OAuthHttpError) => {
if (error.wwwAuthenticate) {
c.header("WWW-Authenticate", error.wwwAuthenticate);
}
return c.json(
{ error: error.error, error_description: error.message },
{ status: error.status as 400 | 401 | 403 },
);
};
export const createHandleMcp =
({
options,
path,
server,
}: {
options: McpRouteOptions;
path: string;
server: McpServer;
}) =>
async (c: LeafMcpContext) => {
let auth: McpAuth;
try {
auth = await buildAuthForRequest({
headers: c.req.raw.headers,
flags: options,
logger: options.logger,
resourceUrl: options.resourceUrl,
});
} catch (error) {
if (error instanceof OAuthHttpError) {
return oauthErrorResponse(c, error);
}
throw error;
}
setIncomingAuth({ c, auth });
await server.startHTTP({
url: new URL(c.req.url),
httpPath: path,
req: c.env.incoming,
res: c.env.outgoing,
options: { sessionIdGenerator: randomUUID },
});
return RESPONSE_ALREADY_SENT;
};

View File

@@ -0,0 +1,12 @@
import { getProtectedResourceMetadata } from "../auth/protectedResourceMetadata.js";
import type { LeafMcpContext, McpRouteOptions } from "../types.js";
export const createHandleProtectedResourceMetadata =
({ options }: { options: McpRouteOptions }) =>
(c: LeafMcpContext) =>
c.json(
getProtectedResourceMetadata({
resourceUrl: options.resourceUrl,
serverURL: options["server-url"],
}),
);

View File

@@ -0,0 +1,29 @@
import { createAutumnOperationsMCPServer } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import { Hono } from "hono";
import { MCP_PATH, PROTECTED_RESOURCE_METADATA_PATH } from "./constants.js";
import { createHandleMcp } from "./handlers/handleMcp.js";
import { createHandleProtectedResourceMetadata } from "./handlers/handleProtectedResourceMetadata.js";
import type { McpRouteOptions } from "./types.js";
export const createMcpRouter = (options: McpRouteOptions) => {
const router = new Hono<{ Bindings: HttpBindings }>();
const mcpServer = createAutumnOperationsMCPServer();
router.get(
PROTECTED_RESOURCE_METADATA_PATH,
createHandleProtectedResourceMetadata({
options,
}),
);
router.all(
MCP_PATH,
createHandleMcp({
options,
path: MCP_PATH,
server: mcpServer,
}),
);
return router;
};

View File

@@ -0,0 +1,15 @@
import type { AutumnLogger } from "@autumn/logging";
import type { MCPServerFlags, OAuthEnvironment } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import type { Context, Hono } from "hono";
export interface McpRouteOptions extends MCPServerFlags {
readonly "oauth-enabled": boolean;
readonly "oauth-environment": OAuthEnvironment;
readonly logger: AutumnLogger;
readonly resourceUrl: string;
}
export type LeafMcpContext = Context<{ Bindings: HttpBindings }>;
export type LeafMcpRouter = Hono<{ Bindings: HttpBindings }>;
export type { MCPOAuthFlags } from "./auth/resolveRequestAuth.js";

View File

@@ -0,0 +1,5 @@
export const braintrustConfig = {
enabled: true,
projectName: process.env.LEAF_BRAINTRUST_PROJECT ?? "leaf",
serviceName: process.env.LEAF_BRAINTRUST_SERVICE ?? "leaf",
};

View File

@@ -0,0 +1,15 @@
import { initLogger, type Logger } from "braintrust";
import { braintrustConfig } from "./config.js";
export const createBraintrustLogger = ({
apiKey = process.env.BRAINTRUST_API_KEY,
enabled = braintrustConfig.enabled,
projectName = braintrustConfig.projectName,
}: {
apiKey?: string;
enabled?: boolean;
projectName?: string;
} = {}): Logger<true> | undefined => {
if (!enabled || !apiKey) return undefined;
return initLogger({ apiKey, projectName });
};

View File

@@ -0,0 +1,49 @@
import { BraintrustExporter } from "@mastra/braintrust";
import { SpanType } from "@mastra/core/observability";
import { Observability, SamplingStrategyType } from "@mastra/observability";
import { currentSpan } from "braintrust";
import { braintrustConfig } from "./config.js";
import { createBraintrustLogger } from "./createBraintrustLogger.js";
export const createMastraBraintrustObservability = ({
apiKey = process.env.BRAINTRUST_API_KEY,
enabled = braintrustConfig.enabled,
projectName = braintrustConfig.projectName,
serviceName = braintrustConfig.serviceName,
braintrustLogger = createBraintrustLogger({
apiKey,
enabled,
projectName,
}),
}: {
apiKey?: string;
braintrustLogger?: unknown;
enabled?: boolean;
projectName?: string;
serviceName?: string;
} = {}): Observability | undefined => {
if (!enabled) return undefined;
const exporterConfig = {
apiKey,
braintrustLogger,
currentSpan: () => currentSpan(),
projectName,
} as unknown as ConstructorParameters<typeof BraintrustExporter>[0];
return new Observability({
configs: {
braintrust: {
excludeSpanTypes: [SpanType.MODEL_CHUNK],
exporters: [new BraintrustExporter(exporterConfig)],
sampling: { type: SamplingStrategyType.ALWAYS },
serializationOptions: {
maxArrayLength: 50,
maxDepth: 6,
maxObjectKeys: 80,
maxStringLength: 8_000,
},
serviceName,
},
},
});
};

View File

@@ -0,0 +1,3 @@
export { braintrustConfig } from "./config.js";
export { createBraintrustLogger } from "./createBraintrustLogger.js";
export { createMastraBraintrustObservability } from "./createMastraBraintrustObservability.js";

View File

@@ -0,0 +1,46 @@
import type { SandboxFile } from "../../agent/sandbox/types.js";
import type { E2bSandbox } from "./e2bSandboxLifecycle.js";
export const e2bWorkDir = "/work";
export const ensureE2bWorkDir = async ({
sandbox,
}: {
sandbox: E2bSandbox;
}) => {
try {
await sandbox.files.makeDir(e2bWorkDir);
} catch (error) {
if (!(error instanceof Error) || !/exist/i.test(error.message)) throw error;
}
};
export const writeE2bSandboxFiles = async ({
files,
sandbox,
}: {
files: SandboxFile[];
sandbox: E2bSandbox;
}) => {
for (const file of files) {
await sandbox.files.write(file.path, file.content);
}
};
export const readRequestedE2bFiles = async ({
returnFiles,
sandbox,
}: {
returnFiles: string[];
sandbox: E2bSandbox;
}): Promise<SandboxFile[]> => {
const files: SandboxFile[] = [];
for (const filePath of returnFiles) {
if (!(await sandbox.files.exists(filePath))) continue;
files.push({
path: filePath,
content: await sandbox.files.read(filePath),
});
}
return files;
};

View File

@@ -0,0 +1,78 @@
import { Sandbox } from "e2b";
import type { SandboxSessionContext } from "../../agent/sandbox/types.js";
import {
e2bSandboxLookupMetadata,
e2bSandboxMetadata,
} from "./e2bSandboxMetadata.js";
export type E2bSandbox = Awaited<ReturnType<typeof Sandbox.create>>;
export const findE2bSandbox = async ({
apiKey,
context,
}: {
apiKey: string;
context: SandboxSessionContext;
}) => {
const paginator = Sandbox.list({
apiKey,
query: {
metadata: e2bSandboxLookupMetadata({ context }),
state: ["running", "paused"],
},
});
const matches = await paginator.nextItems();
return matches[0];
};
export const createE2bSandbox = ({
apiKey,
context,
timeoutMs,
}: {
apiKey: string;
context: SandboxSessionContext;
timeoutMs: number;
}) =>
Sandbox.create({
allowInternetAccess: false,
apiKey,
metadata: e2bSandboxMetadata({ context }),
network: { allowPublicTraffic: false },
timeoutMs,
});
export const connectE2bSandbox = ({
apiKey,
sandboxId,
timeoutMs,
}: {
apiKey: string;
sandboxId: string;
timeoutMs: number;
}) =>
Sandbox.connect(sandboxId, {
apiKey,
timeoutMs,
});
export const findOrCreateE2bSandbox = async ({
apiKey,
context,
timeoutMs,
}: {
apiKey: string;
context: SandboxSessionContext;
timeoutMs: number;
}) => {
const existing = await findE2bSandbox({ apiKey, context });
if (existing) {
return connectE2bSandbox({
apiKey,
sandboxId: existing.sandboxId,
timeoutMs,
});
}
return createE2bSandbox({ apiKey, context, timeoutMs });
};

View File

@@ -0,0 +1,40 @@
import type { SandboxSessionContext } from "../../agent/sandbox/types.js";
const metadataApp = "leaf";
export const e2bThreadKey = ({ context }: { context: SandboxSessionContext }) =>
[
context.orgId,
context.env,
context.provider,
context.workspaceId,
context.channelId,
context.threadId,
].join(":");
export const e2bSandboxMetadata = ({
context,
}: {
context: SandboxSessionContext;
}) => ({
app: metadataApp,
channelId: context.channelId,
env: context.env,
orgId: context.orgId,
provider: context.provider,
threadId: context.threadId,
threadKey: e2bThreadKey({ context }),
workspaceId: context.workspaceId,
});
export const e2bSandboxLookupMetadata = ({
context,
}: {
context: SandboxSessionContext;
}) => {
const metadata = e2bSandboxMetadata({ context });
return {
app: metadata.app,
threadKey: metadata.threadKey,
};
};

View File

@@ -0,0 +1,55 @@
import type {
SandboxProvider,
SandboxRunResult,
SandboxSessionContext,
} from "../../agent/sandbox/types.js";
import {
e2bWorkDir,
ensureE2bWorkDir,
readRequestedE2bFiles,
writeE2bSandboxFiles,
} from "./e2bSandboxFiles.js";
import { findOrCreateE2bSandbox } from "./e2bSandboxLifecycle.js";
export const createE2bSandboxProvider = ({
apiKey,
context,
sessionTimeoutMs,
}: {
apiKey: string;
context: SandboxSessionContext;
sessionTimeoutMs: number;
}): SandboxProvider => ({
run: async ({ command, files, returnFiles, timeoutMs }) => {
const sandbox = await findOrCreateE2bSandbox({
apiKey,
context,
timeoutMs: sessionTimeoutMs,
});
try {
await ensureE2bWorkDir({ sandbox });
await writeE2bSandboxFiles({ files, sandbox });
const result = await sandbox.commands.run(command, {
cwd: e2bWorkDir,
timeoutMs,
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
exitCode: result.exitCode,
timedOut: false,
files: await readRequestedE2bFiles({ returnFiles, sandbox }),
} satisfies SandboxRunResult;
} catch (error) {
const timedOut =
error instanceof Error && /timeout|timed out/i.test(error.message);
if (!timedOut) throw error;
return {
stdout: "",
stderr: "Sandbox command timed out",
timedOut: true,
files: await readRequestedE2bFiles({ returnFiles, sandbox }),
};
}
},
});

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
const slackWorkspaceSchema = z.preprocess(
(value) => {
const payload =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return {
workspaceId:
payload.team_id ??
(payload.team as Record<string, unknown> | undefined)?.id ??
(typeof payload.team === "string" ? payload.team : undefined) ??
(payload.user as Record<string, unknown> | undefined)?.team_id,
};
},
z.strictObject({ workspaceId: z.string() }),
);
export const getSlackWorkspaceId = (raw: unknown) =>
slackWorkspaceSchema.parse(raw).workspaceId;

View File

@@ -0,0 +1,105 @@
import type { Attachment } from "chat";
const SLACK_FILES_INFO_URL = "https://slack.com/api/files.info";
type SlackRawFile = {
id?: string;
mimetype?: string;
name?: string;
size?: number;
url_private?: string;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const parseSlackFile = (value: unknown): SlackRawFile | null => {
if (!isRecord(value)) return null;
return {
id: typeof value.id === "string" ? value.id : undefined,
mimetype: typeof value.mimetype === "string" ? value.mimetype : undefined,
name: typeof value.name === "string" ? value.name : undefined,
size: typeof value.size === "number" ? value.size : undefined,
url_private:
typeof value.url_private === "string" ? value.url_private : undefined,
};
};
export const getSlackFilesFromRaw = ({ raw }: { raw: unknown }) => {
if (!isRecord(raw) || !Array.isArray(raw.files)) return [];
return raw.files.flatMap((file) => {
const parsed = parseSlackFile(file);
return parsed ? [parsed] : [];
});
};
const findRawFileForAttachment = ({
attachment,
files,
}: {
attachment: Attachment;
files: SlackRawFile[];
}) =>
files.find(
(file) =>
file.name === attachment.name &&
file.mimetype === attachment.mimeType &&
file.size === attachment.size,
) ?? files.find((file) => file.name === attachment.name);
const fetchSlackPrivateUrl = async ({
botToken,
url,
}: {
botToken: string;
url: string;
}) => {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${botToken}` },
});
if (!response.ok) {
throw new Error(`Slack file download failed: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
};
const fetchSlackFileInfoUrl = async ({
botToken,
fileId,
}: {
botToken: string;
fileId: string;
}) => {
const url = new URL(SLACK_FILES_INFO_URL);
url.searchParams.set("file", fileId);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${botToken}` },
});
if (!response.ok)
throw new Error(`Slack files.info failed: ${response.status}`);
const data = await response.json();
if (!isRecord(data) || data.ok !== true || !isRecord(data.file)) return null;
return typeof data.file.url_private === "string"
? data.file.url_private
: null;
};
export const fetchSlackAttachmentFallback = async ({
attachment,
botToken,
rawFiles,
}: {
attachment: Attachment;
botToken: string;
rawFiles: SlackRawFile[];
}) => {
const rawFile = findRawFileForAttachment({ attachment, files: rawFiles });
if (!rawFile) return null;
const url =
rawFile.url_private ??
(rawFile.id
? await fetchSlackFileInfoUrl({ botToken, fileId: rawFile.id })
: null);
if (!url) return null;
return fetchSlackPrivateUrl({ botToken, url });
};

View File

@@ -0,0 +1,149 @@
import crypto from "node:crypto";
import {
AppEnv,
apiKeys,
type ChatInstallation,
type ChatInstallState,
type ChatProvider,
chatInstallations,
organizations,
} from "@autumn/shared";
import { and, eq, or } from "drizzle-orm";
import { replaceInstallationOAuthCredentials } from "../../internal/installations/actions/replaceInstallationOAuthCredentials.js";
import { decrypt, encrypt } from "../../lib/crypto.js";
import { db } from "../../lib/db.js";
import { env } from "../../lib/env.js";
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
export const getStateSecret = () => env.CHAT_STATE_SECRET;
export const findInstallation = (provider: ChatProvider, workspaceId: string) =>
db.query.chatInstallations.findFirst({
where: and(
eq(chatInstallations.provider, provider),
eq(chatInstallations.workspace_id, workspaceId),
),
});
export type ChatInstallationWithOrg = ChatInstallation & {
org_slug?: string;
};
export const findInstallationWithOrg = async (
provider: ChatProvider,
workspaceId: string,
): Promise<ChatInstallationWithOrg | undefined> => {
const [row] = await db
.select({
installation: chatInstallations,
orgSlug: organizations.slug,
})
.from(chatInstallations)
.innerJoin(organizations, eq(organizations.id, chatInstallations.org_id))
.where(
and(
eq(chatInstallations.provider, provider),
eq(chatInstallations.workspace_id, workspaceId),
),
)
.limit(1);
return row
? {
...row.installation,
org_slug: row.orgSlug,
}
: undefined;
};
export const getInstallationKey = (
installation: ChatInstallation,
env: AppEnv,
) => {
const key =
env === AppEnv.Live
? installation.live_api_key
: installation.sandbox_api_key;
if (!key) throw new Error(`Missing ${env} API key`);
return decrypt(key);
};
const deleteInstallationApiKeys = async (
tx: ChatTransaction,
installation: ChatInstallation,
) => {
for (const id of [
installation.sandbox_api_key_id,
installation.live_api_key_id,
]) {
if (!id) continue;
await tx
.delete(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.org_id, installation.org_id)));
}
};
export const replaceInstallation = async ({
state,
provider,
workspaceId,
workspaceName,
botUserId,
botAccessToken,
scopes,
installedByProviderUserId,
}: {
state: ChatInstallState;
provider: ChatProvider;
workspaceId: string;
workspaceName: string;
botUserId?: string;
botAccessToken: string;
scopes: string[];
installedByProviderUserId?: string;
}) => {
const sameOrg = and(
eq(chatInstallations.org_id, state.orgId),
eq(chatInstallations.provider, provider),
);
const sameWorkspace = and(
eq(chatInstallations.provider, provider),
eq(chatInstallations.workspace_id, workspaceId),
);
await db.transaction(async (tx) => {
const existingInstallations = await tx.query.chatInstallations.findMany({
where: or(sameOrg, sameWorkspace),
});
for (const installation of existingInstallations) {
await deleteInstallationApiKeys(tx, installation);
}
await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace));
const [installation] = await tx
.insert(chatInstallations)
.values({
id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
org_id: state.orgId,
provider,
workspace_id: workspaceId,
workspace_name: workspaceName,
bot_user_id: botUserId,
bot_access_token: encrypt(botAccessToken),
scopes,
default_env: state.env,
installed_by_user_id: state.userId,
installed_by_provider_user_id: installedByProviderUserId,
created_at: Date.now(),
updated_at: Date.now(),
})
.returning();
await replaceInstallationOAuthCredentials({
tx,
installation,
userId: state.userId,
});
});
};

View File

@@ -0,0 +1,45 @@
import { z } from "zod";
import { env } from "../../lib/env.js";
const SLACK_OAUTH_URL = "https://slack.com/api/oauth.v2.access";
const slackOAuthBaseSchema = z
.object({
ok: z.boolean(),
error: z.string().optional(),
})
.passthrough();
const slackOAuthSuccessSchema = z.object({
access_token: z.string(),
scope: z.string().optional(),
bot_user_id: z.string().optional(),
team: z.object({ id: z.string(), name: z.string() }),
authed_user: z.object({ id: z.string() }).optional(),
});
export const exchangeSlackCode = async (code: string) => {
const body = new URLSearchParams({
client_id: env.SLACK_CLIENT_ID,
client_secret: env.SLACK_CLIENT_SECRET,
code,
});
if (env.SLACK_REDIRECT_URI) {
body.set("redirect_uri", env.SLACK_REDIRECT_URI);
}
const response = await fetch(SLACK_OAUTH_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const data = slackOAuthBaseSchema.parse(await response.json());
if (!data.ok)
throw new Error(`Slack OAuth failed: ${data.error ?? "unknown"}`);
return slackOAuthSuccessSchema.parse(data);
};
export const slackSuccessUrl = () =>
`${env.CLIENT_URL}/settings?tab=integrations&chat=connected`;
export const slackErrorUrl = (error: string) =>
`${env.CLIENT_URL}/settings?tab=integrations&chat_error=${encodeURIComponent(error)}`;

View File

@@ -0,0 +1,79 @@
import { type ChatProvider, verifyChatInstallState } from "@autumn/shared";
import { Hono } from "hono";
import { z } from "zod";
import { bot } from "../../bot.js";
import { logger } from "../../lib/logger.js";
import { getStateSecret, replaceInstallation } from "./installations.js";
import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js";
const callbackQuery = z.strictObject({
code: z.string(),
state: z.string(),
});
const isSlackInstallProvider = (provider: string): provider is ChatProvider =>
provider === "slack" ||
provider === "slack_admin" ||
provider.startsWith("slack_admin:");
export const slackRoutes = new Hono();
slackRoutes.get("/oauth/callback", async (c) => {
try {
const { code, state } = callbackQuery.parse({
code: c.req.query("code"),
state: c.req.query("state"),
});
const parsedState = verifyChatInstallState(state, getStateSecret());
if (!parsedState || !isSlackInstallProvider(parsedState.provider))
throw new Error("Invalid or expired Slack OAuth state");
const oauth = await exchangeSlackCode(code);
await replaceInstallation({
state: parsedState,
provider: parsedState.provider,
workspaceId: oauth.team.id,
workspaceName: oauth.team.name,
botUserId: oauth.bot_user_id,
botAccessToken: oauth.access_token,
scopes: String(oauth.scope ?? "")
.split(",")
.filter(Boolean),
installedByProviderUserId: oauth.authed_user?.id,
});
logger.info("[chat:slack] Installed", {
event: "leaf.slack_installed",
context: {
org_id: parsedState.orgId,
slack_workspace_id: oauth.team.id,
},
data: {
workspace_name: oauth.team.name,
},
});
return c.redirect(slackSuccessUrl());
} catch (error) {
logger.error("[chat:slack] OAuth callback failed", error, {
event: "leaf.slack_oauth_failed",
});
return c.redirect(slackErrorUrl("Slack install failed"));
}
});
slackRoutes.post("/events", (c) => {
logger.debug("Received Slack events request", {
event: "leaf.slack_events_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});
slackRoutes.post("/interactions", (c) => {
logger.debug("Received Slack interactions request", {
event: "leaf.slack_interactions_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});

View File

@@ -0,0 +1,30 @@
import type { Message, Thread } from "chat";
import type { ChatContextMessage } from "../../types.js";
export const getRecentMessages = async (
thread: Thread,
currentMessage: Message,
): Promise<ChatContextMessage[]> => {
try {
await thread.refresh();
} catch (error) {
console.warn("[chat] Could not refresh thread context", error);
}
const seen = new Set<string>();
return [...thread.recentMessages, currentMessage]
.filter((message) => {
if (seen.has(message.id) || !message.text.trim()) return false;
seen.add(message.id);
return true;
})
.slice(-8)
.map((message) => ({
author:
message.author.fullName ||
message.author.userName ||
message.author.userId,
isBot: message.author.isBot,
text: message.text,
}));
};

88
apps/leaf/src/types.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { AutumnLogger } from "@autumn/logging";
import { AppEnv, type ChatInstallation } from "@autumn/shared";
import type { Attachment } from "chat";
import { z } from "zod";
export type LeafChatInstallation = ChatInstallation & {
org_slug?: string;
};
export const agentOutputSchema = z.preprocess(
(value) => {
const payload =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const suspendPayload = payload.suspendPayload as
| Record<string, unknown>
| undefined;
const previewApproval = payload.previewApproval as
| Record<string, unknown>
| undefined;
return {
text: payload.text,
env: payload.env,
finishReason: payload.finishReason,
runId: payload.runId,
suspendPayload: suspendPayload && {
toolCallId: suspendPayload.toolCallId,
toolName: suspendPayload.toolName,
args: suspendPayload.args,
},
previewApproval: previewApproval && {
toolName: previewApproval.toolName,
toolArgs: previewApproval.toolArgs,
preview: previewApproval.preview,
},
};
},
z.strictObject({
text: z.string().optional(),
env: z.nativeEnum(AppEnv),
finishReason: z.string().optional(),
runId: z.string().optional(),
suspendPayload: z
.strictObject({
toolCallId: z.string().optional(),
toolName: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
previewApproval: z
.strictObject({
toolName: z.string(),
toolArgs: z.record(z.string(), z.unknown()),
preview: z.unknown(),
})
.optional(),
}),
);
export type AgentOutput = z.infer<typeof agentOutputSchema>;
export type SignatureArgs = {
body: string;
timestamp?: string | null;
signature?: string | null;
};
export type BotMessage = {
agentRunId?: string;
attachmentFetchFallback?: (params: {
attachment: Attachment;
}) => Promise<Buffer | null>;
attachments?: Attachment[];
installation: LeafChatInstallation;
logger?: AutumnLogger;
onAction?: (message: string) => Promise<void> | void;
recentMessages?: ChatContextMessage[];
text: string;
channelId: string;
threadId: string;
};
export type ChatContextMessage = {
author: string;
isBot: boolean | "unknown";
text: string;
};

254
apps/leaf/src/ui/blocks.ts Normal file
View File

@@ -0,0 +1,254 @@
import type { AppEnv } from "@autumn/shared";
import { Actions, Button, Card, CardText, Divider, Field, Fields } from "chat";
import { toolLabel } from "../agent/toolPolicy.js";
const formatPreview = (preview: unknown) =>
typeof preview === "string"
? preview
: "";
const getRequest = (args?: Record<string, unknown>) =>
(args?.request && typeof args.request === "object"
? args.request
: args) as Record<string, unknown> | undefined;
const getFieldValue = (value: unknown) =>
typeof value === "string" || typeof value === "number"
? String(value)
: typeof value === "boolean"
? value
? "Yes"
: "No"
: null;
const getRecord = (value: unknown) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
const formatPrice = (request: Record<string, unknown>) => {
const customize = getRecord(request.customize);
const price = getRecord(customize.price);
const amount = getFieldValue(price.amount);
const interval = getFieldValue(price.interval);
return amount ? `$${amount}${interval ? `/${interval}` : ""}` : null;
};
const formatInvoiceMode = (value: unknown) => {
if (typeof value === "boolean") return value ? "enabled" : "disabled";
const invoiceMode = getRecord(value);
if (!Object.keys(invoiceMode).length) return null;
return [
invoiceMode.enabled === true
? "enabled"
: invoiceMode.enabled === false
? "disabled"
: null,
invoiceMode.finalize === false
? "draft invoice"
: invoiceMode.finalize === true
? "finalize invoice"
: null,
invoiceMode.enable_plan_immediately === true
? "enable immediately"
: invoiceMode.enable_plan_immediately === false
? "access waits"
: null,
]
.filter((part): part is string => Boolean(part))
.join(", ");
};
const envLabel = (env?: AppEnv) =>
env === "live" ? "Live" : env === "sandbox" ? "Sandbox" : null;
const requestFields = ({
env,
toolName,
toolArgs,
}: {
env?: AppEnv;
toolName: string;
toolArgs?: Record<string, unknown>;
}) => {
const request = getRequest(toolArgs);
const environment = envLabel(env);
const baseFields = [
Field({
label: "Action",
value: toolLabel(toolName),
}),
...(environment
? [Field({ label: "Environment", value: environment })]
: []),
];
if (!request) return baseFields;
return [
...baseFields,
...[
["Customer", request.customer_id],
["Plan", request.plan_id],
["Entity", request.entity_id],
["Subscription", request.subscription_id],
["Price", formatPrice(request)],
["Enable immediately", request.enable_plan_immediately],
["Invoice mode", formatInvoiceMode(request.invoice_mode)],
["Proration", request.proration_behavior],
["Redirect", request.redirect_mode],
].flatMap(([label, value]) => {
const fieldValue = getFieldValue(value);
return fieldValue ? [Field({ label: String(label), value: fieldValue })] : [];
}),
].slice(0, 8);
};
const cleanPreviewLine = (line: string) =>
line
.replace(/^[-*•]\s*/, "")
.replace(/\*\*/g, "")
.replace(/__+/g, "")
.trim();
const previewLines = (preview: unknown) =>
formatPreview(preview)
.replace(
/\s*(Plan:|Customer:|Description|Amount|Total|Discounts?:|Payment will|No discounts|No existing)/g,
"\n$1",
)
.split(/\n+/)
.map(cleanPreviewLine)
.filter(Boolean)
.filter(
(line) =>
!/^(i('|)ll|let me|here('|)s|would you like|shall i|tool:|[\{\}\"])/i.test(
line,
),
)
.slice(0, 8);
const resultLines = (result: unknown) => {
if (!result) return [];
if (typeof result === "string") return [result];
if (typeof result !== "object") return [String(result)];
const body = result as Record<string, unknown>;
const resultBody = getRecord(body.result);
const nested = resultBody.message || resultBody.status ? resultBody : getRecord(body.data);
const value = (key: string) => body[key] ?? nested[key];
const message = value("message");
const status = value("status");
const id = value("id");
const url = value("url");
const checkoutUrl = value("checkout_url");
return [
typeof message === "string" ? message : null,
typeof status === "string" ? `Status: ${status}` : null,
typeof id === "string" ? `ID: ${id}` : null,
typeof url === "string" ? `URL: ${url}` : null,
typeof checkoutUrl === "string" ? `Checkout URL: ${checkoutUrl}` : null,
]
.filter((line): line is string => Boolean(line))
.slice(0, 6);
};
const statusLines = ({
status,
result,
}: {
status: "approved" | "cancelled" | "failed" | "running";
result?: unknown;
}) => {
const lines = resultLines(result);
if (lines.length) return lines;
if (status === "running") return ["Applying the approved action now..."];
if (status === "cancelled") return ["No changes were made."];
return status === "failed" ? ["The action failed."] : [];
};
export const approvalCard = ({
env,
id,
toolName,
toolArgs,
preview,
}: {
env?: AppEnv;
id: string;
toolName: string;
toolArgs?: Record<string, unknown>;
preview?: unknown;
}) =>
{
const fields = requestFields({ env, toolName, toolArgs });
const lines = preview ? previewLines(preview) : [];
return Card({
title: `${toolLabel(toolName)}?`,
subtitle: "Review the preview before this runs",
children: [
...(fields.length ? [Fields(fields)] : []),
...(lines.length
? [Divider(), CardText(lines.map((line) => `${line}`).join("\n"))]
: []),
Actions([
Button({
id: "approve_billing_action",
label: "Approve",
style: "primary",
value: id,
}),
Button({
id: "cancel_billing_action",
label: "Cancel",
style: "danger",
value: id,
}),
]),
],
});
};
export const approvalStatusCard = ({
env,
status,
toolName,
toolArgs,
preview,
result,
}: {
env?: AppEnv;
status: "approved" | "cancelled" | "failed" | "running";
toolName: string;
toolArgs?: Record<string, unknown>;
preview?: unknown;
result?: unknown;
}) => {
const fields = requestFields({ env, toolName, toolArgs });
const lines = statusLines({ status, result });
const title =
status === "approved"
? `${toolLabel(toolName)} approved`
: status === "cancelled"
? `${toolLabel(toolName)} cancelled`
: status === "running"
? `Running ${toolLabel(toolName)}`
: `${toolLabel(toolName)} failed`;
return Card({
title,
subtitle:
status === "running"
? "Applying the approved action"
: "The approval is closed",
children: [
...(fields.length ? [Fields(fields)] : []),
...(lines.length
? [
Divider(),
CardText(lines.map((line) => `${cleanPreviewLine(line)}`).join("\n")),
]
: []),
],
});
};

View File

@@ -0,0 +1,56 @@
import type { Channel, Thread } from "chat";
import { Plan } from "chat";
export type ReplyTarget = Thread | Channel;
export type LoadingState = Plan | null;
export const startLoading = async (target: ReplyTarget) => {
try {
await target.startTyping("Starting Autumn...");
const loading = new Plan({ initialMessage: "Starting Autumn..." });
await target.post(loading);
return loading;
} catch (error) {
console.warn("[chat] Could not show loading state", error);
return null;
}
};
export const createActionLogger = (loading: LoadingState) => {
const seen = new Set<string>();
let first = true;
return async (message: string) => {
if (!loading || seen.has(message)) return;
seen.add(message);
try {
if (first) {
first = false;
await loading.reset({ initialMessage: message });
return;
}
await loading.addTask({ title: message });
} catch (error) {
console.warn("[chat] Could not update loading state", error);
}
};
};
export const finishLoading = async (
target: ReplyTarget,
loading: LoadingState,
message: string,
) => {
if (!loading) {
await target.post({ markdown: message });
return;
}
try {
await loading.complete({ completeMessage: message });
} catch (error) {
console.warn("[chat] Could not complete loading state", error);
await target.post({ markdown: message });
}
};

View File

@@ -0,0 +1,39 @@
# Leaf Evals
Braintrust evals for Leaf and Autumn MCP behavior.
## Run
```sh
bun -F @autumn/leaf eval:mcp
```
Eval files set `noSendLogs` when `BRAINTRUST_API_KEY` is absent, but agent
model calls still need the normal model provider environment.
## Pattern
- Build setup state with `fixtures/*` builders.
- Create runtime state with `context/createEvalContext`.
- Use a driver factory, usually `createGenericMcpAgentDriver`, to exercise the runtime.
- Assert behavior with deterministic scorers before adding LLM judges.
- Keep real customer/org names out of fixtures; use setup tags like
`invoice-mode-customer-missing-email`.
- Keep `trace.event(...)` terminal-only. Braintrust spans should come from the
Mastra observability exporter unless a test explicitly needs custom spans.
## Context
The eval context is intentionally split by responsibility:
- `harness/context` owns the mock Autumn API and local MCP server.
- `harness/configs` owns defaults and reusable eval configuration objects.
- `harness/drivers` owns agent/client variants that talk to the MCP server.
- `harness/tracing` owns local terminal visibility for user turns, tool calls,
API calls, and approvals.
Eval files should read like scenarios: choose a fixture setup, create a context,
run conversation turns, return scorer output.
The old MCP evals under `packages/mcp/tests/evals` are intentionally left in
place while this structure is proven out.

View File

@@ -0,0 +1,95 @@
import {
billing,
response,
tools,
} from "../../../fixtures/expectations/index.js";
import { withCustomers } from "../../../fixtures/createSetup.js";
import { orgSetups } from "../../../fixtures/orgSetups.js";
import { approve, initEval, user } from "../../../harness/index.js";
import { billingAttachScores } from "../../../utils/scorers.js";
type EvalMetadata = {
domain: "billing";
flow: "attach";
};
const experimentName = "attach-custom-price";
const customPrice = 49;
const setup = withCustomers({
setup: orgSetups.knowledgePlatform(),
customers: ({ customers }) => ({
account: customers.base({
email: "billing@northstar.example",
id: "cus_attach_custom_price",
name: "Northstar Labs",
}),
}),
});
const customer = setup.refs.customers.account;
const enterprisePlan = setup.refs.plans.enterprise;
const expectedAttachRequest = {
customer_id: customer.id,
customize: {
price: {
amount: customPrice,
interval: "month",
},
},
enable_plan_immediately: true,
invoice_mode: {
enable_plan_immediately: true,
enabled: true,
finalize: false,
},
plan_id: enterprisePlan.id,
redirect_mode: "if_required",
};
initEval<EvalMetadata>({
experimentName,
setup,
metadata: {
domain: "billing",
flow: "attach",
},
scores: billingAttachScores(),
cases: [
{
name: "custom monthly price with draft invoice",
conversation: [
user({
message:
"Please attach the Enterprise plan to Northstar Labs with a custom base price of $49/month.",
}),
user({ message: "Looks good, attach it." }),
approve(),
],
expect: [
tools.called({
toolNames: ["listCustomers", "listPlans"],
}),
billing.previewBeforeWrite({
preview: {
body: expectedAttachRequest,
toolName: "previewAttach",
},
write: {
body: expectedAttachRequest,
toolName: "attach",
},
}),
response.mentions({
phrases: [
"Northstar Labs",
"Enterprise",
"$49",
"invoice",
"immediately",
],
}),
],
},
],
});

View File

@@ -0,0 +1,230 @@
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule";
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js";
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
import {
balances as balanceFixtures,
customers as customerFixtures,
customerList as customerListFixture,
schedules as scheduleFixtures,
subscriptions as subscriptionFixtures,
} from "./customers/index.js";
import {
basePrice as basePriceFixture,
features as featureFixtures,
featureList as featureListFixture,
items as itemFixtures,
itemList as itemListFixture,
plan as planFixture,
planList as planListFixture,
} from "./plans/index.js";
import type { EvalSetup, EvalSetupIds, PlanRef, ScheduleRef } from "./types.js";
const flattenRecordValues = <Value>(record: Record<string, Value | Value[]>) =>
Object.values(record).flatMap((value) =>
Array.isArray(value) ? value : [value],
);
const refIds = <Value extends { id?: string | null }>(
record: Record<string, Value | Value[]>,
) =>
Object.fromEntries(
Object.entries(record).map(([key, value]) => [
key,
Array.isArray(value) ? value.map((item) => item.id) : value.id,
]),
);
const setupIds = <
Features extends Record<string, ApiFeatureV1>,
Plans extends Record<string, PlanRef>,
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Schedules extends Record<string, ScheduleRef>,
>({
customers,
features,
plans,
schedules,
}: {
customers: Customers;
features: Features;
plans: Plans;
schedules: Schedules;
}) =>
({
customers: refIds(customers),
features: refIds(features),
plans: refIds(plans),
schedules: refIds(schedules),
}) as unknown as EvalSetupIds<Features, Plans, Customers, Schedules>;
/**
* Compose a mock Autumn org for evals from keyed feature, plan, and customer refs.
* The returned arrays feed the mock API; refs keep setup assertions readable.
*/
export const createSetup = <
Features extends Record<string, ApiFeatureV1>,
Plans extends Record<string, PlanRef>,
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
Schedules extends Record<string, ScheduleRef> = Record<string, never>,
>({
customers: createCustomers,
features: createFeatures,
plans: createPlans,
schedules: createSchedules,
tag,
}: {
tag: string;
features: ({
featureList,
features,
}: {
featureList: typeof featureListFixture;
features: typeof featureFixtures;
}) => Features;
plans: ({
basePrice,
features,
itemList,
items,
plan,
planList,
}: {
basePrice: typeof basePriceFixture;
features: Features;
itemList: typeof itemListFixture;
items: typeof itemFixtures;
plan: typeof planFixture;
planList: typeof planListFixture;
}) => Plans;
customers: ({
balances,
customerList,
customers,
features,
plans,
subscriptions,
}: {
balances: typeof balanceFixtures;
customerList: typeof customerListFixture;
customers: typeof customerFixtures;
features: Features;
plans: Plans;
subscriptions: typeof subscriptionFixtures;
}) => Customers;
schedules?: ({
customers,
plans,
schedules,
}: {
customers: Customers;
plans: Plans;
schedules: typeof scheduleFixtures;
}) => Schedules;
}): EvalSetup<Features, Plans, Customers, Schedules> => {
const featureRefs = createFeatures({
featureList: featureListFixture,
features: featureFixtures,
});
const planRefs = createPlans({
basePrice: basePriceFixture,
features: featureRefs,
itemList: itemListFixture,
items: itemFixtures,
plan: planFixture,
planList: planListFixture,
});
const customerRefs = createCustomers({
balances: balanceFixtures,
customerList: customerListFixture,
customers: customerFixtures,
features: featureRefs,
plans: planRefs,
subscriptions: subscriptionFixtures,
});
const scheduleRefs = createSchedules?.({
customers: customerRefs,
plans: planRefs,
schedules: scheduleFixtures,
});
return {
tag,
ids: setupIds({
customers: customerRefs,
features: featureRefs,
plans: planRefs,
schedules: (scheduleRefs ?? {}) as Schedules,
}),
features: Object.values(featureRefs),
plans: flattenRecordValues<ApiPlanV1>(planRefs),
customers: flattenRecordValues<BaseApiCustomerV5>(customerRefs),
schedules: flattenRecordValues<ApiCustomerSchedule>(
(scheduleRefs ?? {}) as Schedules,
),
refs: {
features: featureRefs,
plans: planRefs,
customers: customerRefs,
schedules: (scheduleRefs ?? {}) as Schedules,
},
};
};
/** Extend an org setup with eval-specific customers while preserving typed refs. */
export const withCustomers = <
Setup extends EvalSetup,
Customers extends Record<string, BaseApiCustomerV5 | BaseApiCustomerV5[]>,
>({
customers: createCustomers,
setup,
}: {
setup: Setup;
customers: ({
balances,
customerList,
customers,
features,
plans,
subscriptions,
}: {
balances: typeof balanceFixtures;
customerList: typeof customerListFixture;
customers: typeof customerFixtures;
features: Setup["refs"]["features"];
plans: Setup["refs"]["plans"];
subscriptions: typeof subscriptionFixtures;
}) => Customers;
}): EvalSetup<
Setup["refs"]["features"],
Setup["refs"]["plans"],
Customers,
Setup["refs"]["schedules"]
> => {
const customerRefs = createCustomers({
balances: balanceFixtures,
customerList: customerListFixture,
customers: customerFixtures,
features: setup.refs.features,
plans: setup.refs.plans,
subscriptions: subscriptionFixtures,
});
return {
...setup,
ids: setupIds({
customers: customerRefs,
features: setup.refs.features,
plans: setup.refs.plans,
schedules: setup.refs.schedules,
}),
customers: [
...setup.customers,
...flattenRecordValues<BaseApiCustomerV5>(customerRefs),
],
refs: {
...setup.refs,
customers: customerRefs,
},
};
};

View File

@@ -0,0 +1,60 @@
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1.js";
import { ResetInterval } from "@models/productModels/intervals/resetInterval.js";
const dateToEpochMs = (date: Date | null) => date?.getTime() ?? null;
/** Base balance fixture with matching top-level values and one simple breakdown row. */
export const baseBalance = ({
featureId = "credits",
granted = 0,
remaining = granted,
reset = { interval: ResetInterval.Month },
nextResetAt = null,
planId = null,
usage = granted - remaining,
}: {
featureId?: string;
granted?: number;
remaining?: number;
usage?: number;
nextResetAt?: Date | null;
reset?: { interval?: ResetInterval; intervalCount?: number } | null;
planId?: string | null;
} = {}): ApiBalanceV1 => {
const nextResetAtMs = dateToEpochMs(nextResetAt);
const resetValue = reset
? {
interval: reset.interval ?? ResetInterval.Month,
interval_count: reset.intervalCount,
resets_at: nextResetAtMs,
}
: null;
return {
object: "balance",
feature_id: featureId,
granted,
remaining,
usage,
unlimited: false,
overage_allowed: false,
max_purchase: null,
next_reset_at: nextResetAtMs,
breakdown: [
{
object: "balance_breakdown",
id: `balance_${featureId}`,
plan_id: planId,
included_grant: granted,
prepaid_grant: 0,
remaining,
usage,
unlimited: false,
reset: resetValue,
price: null,
expires_at: null,
overage: 0,
},
],
};
};

View File

@@ -0,0 +1,39 @@
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1.js";
import type { ApiSubscriptionV1 } from "@api/customers/cusPlans/apiSubscriptionV1.js";
import { AppEnv } from "@models/genModels/genEnums.js";
const defaultCreatedAt = new Date("2026-01-01T00:00:00.000Z");
/** Base customer API fixture; prefer presets unless you need direct shape control. */
export const baseCustomer = ({
balances = {},
createdAt = defaultCreatedAt,
email = "billing@example.com",
id = "customer_active",
name = "Active Customer",
subscriptions = [],
}: {
id?: string | null;
name?: string | null;
email?: string | null;
createdAt?: Date;
subscriptions?: ApiSubscriptionV1[];
balances?: Record<string, ApiBalanceV1>;
} = {}): BaseApiCustomerV5 => ({
balances,
billing_controls: {},
config: { disable_pooled_balance: false },
created_at: createdAt.getTime(),
env: AppEnv.Sandbox,
id,
email,
fingerprint: null,
flags: {},
metadata: {},
name,
purchases: [],
send_email_receipts: false,
stripe_id: null,
subscriptions,
});

View File

@@ -0,0 +1,35 @@
import type { BaseApiCustomerV5 } from "@api/customers/apiCustomerV5.js";
import type { ApiCustomerSchedule } from "@api/customers/components/apiCustomerSchedule";
const defaultCreatedAt = new Date("2026-01-01T00:00:00.000Z");
export const baseSchedule = ({
createdAt = defaultCreatedAt,
customer,
customerId = customer?.id ?? "customer",
entityId = null,
id = `sched_${customerId}`,
phases,
}: {
createdAt?: Date;
customer?: BaseApiCustomerV5;
customerId?: string;
entityId?: string | null;
id?: string;
phases: Array<{
customerProductIds?: string[];
id?: string;
startsAt: Date;
}>;
}): ApiCustomerSchedule => ({
id,
customer_id: customerId,
entity_id: entityId,
created_at: createdAt.getTime(),
phases: phases.map((phase, index) => ({
id: phase.id ?? `${id}_phase_${index + 1}`,
created_at: createdAt.getTime(),
customer_product_ids: phase.customerProductIds ?? [],
starts_at: phase.startsAt.getTime(),
})),
});

View File

@@ -0,0 +1,39 @@
import type { ApiSubscriptionV1 } from "@api/customers/cusPlans/apiSubscriptionV1.js";
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
const defaultStartedAt = new Date("2026-01-01T00:00:00.000Z");
const dateToEpochMs = (date: Date | null) => date?.getTime() ?? null;
/** Base subscription fixture; pass plan to include the expanded plan response. */
export const baseSubscription = ({
id,
plan,
planId = plan?.id ?? "pro",
status = "active",
startedAt = defaultStartedAt,
currentPeriodStart = startedAt,
currentPeriodEnd = null,
}: {
id?: string;
plan?: ApiPlanV1;
planId?: string;
status?: ApiSubscriptionV1["status"];
startedAt?: Date;
currentPeriodStart?: Date | null;
currentPeriodEnd?: Date | null;
}): ApiSubscriptionV1 => ({
id: id ?? `sub_${planId}`,
plan,
plan_id: planId,
auto_enable: false,
add_on: false,
status,
past_due: false,
canceled_at: null,
expires_at: null,
trial_ends_at: null,
started_at: startedAt.getTime(),
current_period_start: dateToEpochMs(currentPeriodStart),
current_period_end: dateToEpochMs(currentPeriodEnd),
quantity: 1,
});

View File

@@ -0,0 +1,4 @@
export { baseBalance } from "./baseBalance.js";
export { baseCustomer } from "./baseCustomer.js";
export { baseSchedule } from "./baseSchedule.js";
export { baseSubscription } from "./baseSubscription.js";

View File

@@ -0,0 +1,13 @@
export {
baseBalance,
baseCustomer,
baseSchedule,
baseSubscription,
} from "./base/index.js";
export {
balances,
customerList,
customers,
schedules,
subscriptions,
} from "./presets/index.js";

View File

@@ -0,0 +1,6 @@
import { baseBalance } from "../base/baseBalance.js";
export const balances = {
empty: baseBalance,
metered: baseBalance,
} as const;

Some files were not shown because too many files have changed in this diff Show More