Meridian

Slack Decision and Commitment Intelligence Agent

Built for the Slack Agent Builder Challenge.

Uses all three required technologies: Real-Time Search (RTS) API + MCP Server + Slack AI capabilities (Canvas, assistant events, streaming).


The Problem

Every Slack workspace is quietly losing its most important decisions.

A product thread reaches a conclusion after 80 messages. Everyone moves on. Three months later, nobody can find the decision, nobody remembers the alternatives that were considered, and the agent you just deployed to automate that workflow has no idea the decision was ever made.

This is not a search problem. Slack search works. The problem is that decisions are not stored as decisions. They live as unstructured conversation, indistinguishable from "what do you want for lunch?" threads. You cannot search for something that was never structured.

The cost is measurable. Slack's own Workforce Lab research shows workers spend 33 percent of their time searching for information. Large companies lose tens of millions annually to lost institutional knowledge. And with AI agents now proliferating across enterprise stacks, each one acting across different systems with no shared view of what the organization has already decided, the problem is about to get significantly worse.

Meridian solves this at the root. It watches your workspace in real time, extracts decisions and commitments from natural conversation, stores them as structured Canvas artifacts, and exposes everything through an MCP server so any AI agent in your stack can query organizational knowledge directly.


What Meridian Does

Automatic Decision Detection

Meridian monitors channels it is invited to. When a thread contains decision language ("we decided", "going with", "agreed", "lgtm", "approved"), it runs extraction automatically and posts a confirmation card in the thread. No commands required. The card appears as a threaded reply so it does not pollute the channel, only the participants in that thread see it.

One-Click Logging

The confirmation card shows the extracted decision text, a confidence percentage, any alternatives considered that were detected in the thread, and the inferred topic tags. The user has three options:

  • Log It confirms the decision immediately and creates a Canvas document
  • Edit Before Logging opens a modal to correct the extracted text and update topic tags before saving
  • Dismiss marks the detection as rejected without saving anything

Commitment Tracking

When someone writes "I'll handle X by Friday" or "I'm taking this on" in a thread, Meridian captures the commitment text, the owner's Slack user ID, and the due date. It persists this alongside the related decision if one exists in the same thread. When the due date passes without a resolution signal, Meridian sends a DM nudge to the commitment owner with a direct link back to the original thread.

Natural Language Queries via RTS API

Type @Meridian what did we decide about the auth migration? in any channel. Meridian does two things in parallel: it queries the local structured decision store using keyword matching against the decision text and topic tags, and it runs a Real-Time Search API call against live Slack conversations to surface contextually related discussions the team has had anywhere in the workspace. Both result sets are combined and returned as a single threaded reply with permalinks to source messages.

Home Tab Dashboard

The Meridian Home Tab is the decision log dashboard. It shows confirmed decisions sorted newest first with their confidence scores and channel of origin, and it shows all open commitments with their owners and due dates. Each commitment row has an inline Mark Done button so users can fulfill commitments directly from the dashboard without hunting for the original thread.

MCP Server for Downstream Agents

The most architecturally important surface. Meridian exposes four MCP tools that any MCP-compatible AI agent can call:

  • get_decisions(workspace_id, topic, channel_id, since_days, limit) queries the confirmed decision log with optional filters
  • get_commitments(workspace_id, owner_user_id, status, limit) returns open or fulfilled commitments with overdue flags
  • get_decision_by_id(workspace_id, decision_id) fetches one decision with full context: alternatives, participants, canvas link, confidence
  • get_workspace_stats(workspace_id) returns counts and top topics for a full workspace overview

Before any downstream agent takes an action, it can ask Meridian what the team already decided. That is the coordination layer the agentic era is missing.


How We Built It

Starting Point: What Is the Actual Gap

We checked the Slack Marketplace before writing a single line. Summarization bots exist. Q and A bots exist. Workflow automation exists. What does not exist is an agent that captures decisions as first-class structured records from natural conversation AND exposes that corpus through a standard protocol so other agents can consume it without any additional integration. That two-part combination is the gap Meridian fills.

The design principle we worked from: Slack captures your conversations. Meridian captures what they mean.

The Overall Data Flow

When a message arrives in a channel where Meridian is installed, the flow is:

Slack message event
       |
       v
message_listener.py
  -- Does this message contain decision language? (regex check)
  -- Is this a thread reply, not a top-level message?
  -- Has this thread been processed in the last 5 minutes? (cooldown)
       |
       v  (if yes to all three)
thread_processor.py
  -- conversations.replies() to fetch full thread
  -- extract_from_thread() to run the extraction pipeline
  -- For each extracted decision above 0.55 confidence:
       save to store (pending_confirmation)
       post Block Kit confirmation card as threaded reply
  -- For each extracted commitment:
       save to store (open)
       post commitment card as threaded reply
       |
       v  (user clicks "Log It")
decision_actions.py
  -- Update store record to confirmed
  -- canvases.create() with markdown document
  -- canvases.access.set() to share in channel
  -- chat.update() to replace confirmation card with confirmed state

Every step is a separate module. The thread processor does not know about Block Kit. The action handlers do not know about extraction. This separation made it possible to test each layer in isolation.

The Extraction Pipeline in Detail

The extraction pipeline in agent/extractor.py has two layers that are tried in order.

Layer 1: Heuristic extractor

This runs in every environment including offline. It applies compiled regex patterns against each message in the thread and scores confidence based on how many distinct signal patterns match.

The decision patterns cover:

  • Explicit decision verbs: "we decided", "agreed", "approved", "confirmed", "finalized", "settled on"
  • Consensus phrases: "let's go with", "going with", "lgtm", "sounds good"
  • Commitment forms: "I'll", "I will", "I'm going to", "I'll handle", "on me", "taking this"

When a message matches one or more decision patterns, confidence is computed as 0.5 + (number_of_matching_patterns * 0.15) capped at 0.85. A single weak signal gives 0.65. Four overlapping signals give the maximum 0.85. This formula is deterministic: the same text always produces the same confidence score.

Due date extraction runs a separate regex over matched commitment messages looking for relative date phrases ("by Friday", "next week", "tomorrow") and named month-day combinations ("July 20"). These are converted to Unix timestamps for the nudge scheduler.

Layer 2: Claude extractor

When ANTHROPIC_API_KEY is set and starts with sk-ant, the extractor calls claude-3-5-haiku-20241022 via the Anthropic API. We chose Haiku specifically: it is the fastest Claude model, costs roughly 0.8 dollars per million input tokens, and for structured extraction tasks its output quality is indistinguishable from Sonnet.

The system prompt instructs Claude to output only valid JSON matching a fixed schema:

{
  "thread_summary": "1-2 sentence summary",
  "decisions": [
    {
      "decision_text": "clear statement of what was decided",
      "alternatives": ["option A", "option B"],
      "owner_user_id": "<@UXXXXXXX> or null",
      "topics": ["auth", "backend"],
      "confidence": 0.0
    }
  ],
  "commitments": [
    {
      "commitment_text": "what was committed to",
      "owner_mention": "<@UXXXXXXX>",
      "owner_name": "Display Name",
      "due_str": "by Friday"
    }
  ]
}

The prompt specifies confidence thresholds explicitly: 0.9 or above means the decision language was explicit and unambiguous, 0.7 to 0.9 means strong implied consensus, 0.5 to 0.7 means weak signal with one person proposing and no clear objection, below 0.5 means skip it entirely. Claude is instructed never to invent user IDs that do not appear in the thread text.

If the Claude API call fails for any reason (network error, rate limit, malformed JSON), the pipeline falls back to the heuristic extractor silently. The caller never sees the failure.

Why both layers

The heuristic layer makes the demo work without any API key. More importantly, it makes the confidence score recomputable without an external dependency. This matters for the demo: you can show the score changing live in response to thread content, and because the heuristic is a published algorithm (regex patterns with a documented formula), the score is provably not fabricated. The Claude layer adds semantic understanding for production use: it catches decisions phrased in unusual ways, correctly identifies alternatives that were debated, and resolves user mentions accurately.

The Confidence Score as a Demo Device

The confidence score on the confirmation card is the single most important demo element. Here is why.

Every judge at a hackathon has seen demos where an AI agent appears to detect something impressive, and the natural question is "did you hardcode that finding?" The correct response to that question is to show the detection changing in real time based on input.

The demo script for Meridian:

  1. Open a channel thread with five casual messages. No decision language. Meridian is silent.
  2. Add one message: "Agreed, going with Postgres." The card appears with 65 percent confidence.
  3. Add a second message: "lgtm, confirmed." The thread score would recalculate to 80 percent if reprocessed.
  4. Dismiss the card and @mention Meridian in the thread. Show the new card with updated confidence.

The score is not hardcoded. It is not a fixed result. It changes because the thread changed. The formula is published in agent/extractor.py and anyone can read it. This transforms the demo from "trust me" to "verify it yourself."

Real-Time Search API: What Was Non-Obvious

The RTS API documentation is clear on scopes and endpoints but two things tripped us up in implementation.

The action_token requirement

Bot token calls to assistant.search.context require an action_token passed explicitly in the JSON body. This token is not the same as the bot token. It comes from the event payload on app_mention, message.im, and message.channels events (when the bot is mentioned). The token is event-scoped and short-lived. You cannot cache it across requests.

The implication: RTS queries can only happen in response to a live Slack event where the bot was mentioned. You cannot run background batch searches. This is intentional by design for permission enforcement, and it shaped how we structured the query flow. The @Meridian what did we decide... interaction works because the mention event carries the token. A background cron job checking for decisions could not use RTS directly.

Semantic search requires a question-shaped query

The RTS API switches from keyword search to semantic (embedding-based) search when the query looks like a natural language question: it starts with a question word or ends with a question mark. The semantic mode returns topically related results even when exact keywords are absent. The keyword mode requires literal matches.

For the @Meridian query handler, we check whether the user's text looks like a question using a simple heuristic (starts with what/when/who/where/how/did/have or ends with ?) and if so, pass it to RTS verbatim to trigger semantic mode. If it looks like a keyword search, we pass it as-is for keyword matching. This gets the best of both modes without requiring the user to know the difference.

The two-source combination

The query handler always checks the local decision store first. This returns structured records with confidence scores, topic tags, channel of origin, and canvas links. Then it runs the RTS query for unstructured conversation context. Combining both gives the user two types of answers: "here is what was formally decided" and "here are related conversations that might have more context." These are surfaced as two labeled sections in the threaded reply.

MCP Server: Design Decisions

Why FastMCP over the raw MCP Python SDK

FastMCP generates JSON schemas automatically from Python type annotations. A function decorated with @mcp.tool becomes a fully described MCP tool with input validation, error handling, and schema documentation without any additional boilerplate. The raw SDK requires you to write the schema manually. For four tools with multiple optional parameters, FastMCP saved substantial time and produced cleaner, more maintainable code.

FastMCP 3.4.4 is the version we pinned because it is the latest stable release that works correctly with Python 3.12 and pydantic v2. Earlier versions had a known incompatibility with pydantic's FieldInfo constructor that causes an import error.

Why JSON strings instead of Python objects

Each MCP tool returns a JSON string rather than a Python dict or dataclass. This was a deliberate choice. JSON strings are:

  • Universally readable by any agent runtime without Python-specific deserialization
  • Loggable and diffable without any transformation
  • Easy to show in a terminal during a demo with no formatting code
  • Self-documenting when printed: the field names explain the structure

The one tradeoff is that callers must json.loads() the result. For MCP-compliant callers, this is a trivial operation.

The singleton store pattern

The store is accessed via get_store() which returns a module-level singleton initialized on first call. This means the MCP server and the Bolt app share the same in-memory store when run as subprocesses of the same parent process, or persist to the same SQLite file when run separately. For the hackathon demo, running both from the same app.py process means decisions logged through Slack are immediately queryable via the MCP tools with no IPC required.

Tests replace the singleton by monkeypatching storage.store._store directly. This keeps tests fast (no file I/O) and isolated (each test method gets a fresh store).

Tool parameter design

Each tool takes workspace_id as its first parameter because Meridian is designed to be multi-tenant from day one. A single Meridian deployment can serve multiple Slack workspaces, each with their own isolated decision corpus. Downstream agents always pass the workspace ID of the workspace they are operating in, and Meridian's store partitions data by workspace. This is why the Slack team ID (which looks like T0ABC123) is the correct identifier to pass rather than a workspace name or URL.

Canvas Creation: What the API Actually Does

The canvases.create API takes a document_content object with a type of markdown and a markdown string. It supports a specific subset of Markdown elements: headings h1 through h3, bold, italic, bulleted and ordered lists, code spans, code blocks, blockquotes, tables, dividers, and user and channel mentions using Slack's <@UXXXXXXX> and <#CXXXXXXX> syntax.

Meridian generates the canvas markdown in listeners/views/blocks.py using the build_decision_canvas_markdown() function. The document structure per decision is:

# Decision: [first 80 chars of decision text]

**Logged:** [timestamp]
**Channel:** [channel link]
**Confidence:** [percentage]%
**Owner:** [user mention]

## Decision

> [full decision text as blockquote]

## Alternatives Considered
- [alternative 1]
- [alternative 2]

## Topics
`tag1`, `tag2`, `tag3`

## Participants
- @user1
- @user2

## Source
[View original thread](permalink)

After the canvas is created, we call canvases.access.set with access_level: read and the channel ID to make the canvas visible to all members of the channel where the decision was made. This is important: without this call, the canvas is private to the bot user.

The canvas_id returned by canvases.create is stored on the Decision record. The confirmation card and Home Tab both link to the canvas using the slack://canvas?id=FXXXXXXX deep link format.

Storage: Why Two Backends with One Interface

The MemoryStore and SQLiteStore in storage/store.py implement the same interface. Every public method has the same signature and return type. The only difference is persistence.

We built this because the tradeoffs are different during a hackathon versus production:

  • MemoryStore requires zero setup, has zero latency, and is trivially reset by restarting the process. Perfect for a demo.
  • SQLiteStore persists across restarts, supports concurrent reads via SQLite's WAL mode, and can be backed up with a file copy. Perfect for a Marketplace app.

The get_store() factory function reads STORAGE_BACKEND from the environment and returns the appropriate instance. Switching from memory to SQLite in production is a single environment variable change. No application code changes.

Both stores use Python's threading.RLock for thread safety. Bolt processes events concurrently in a thread pool, so upserts and reads must be atomic. The SQLite store acquires the lock before every connection context manager to prevent multiple threads from opening concurrent write transactions.

Event Architecture: Why We Listen to Messages, Not Just Mentions

There are two ways to trigger Meridian:

Active trigger: @Meridian mention in a thread. Immediate, explicit, always works. Handled by app_mentioned.py.

Passive trigger: listening to all messages in channels where Meridian is installed. This is the background auto-detection that makes Meridian feel ambient rather than a tool you have to remember to invoke. Handled by message_listener.py.

The passive trigger has to be careful not to be annoying. Two design decisions prevent it from firing too often:

First, the DECISION_RE compiled regex must match before any extraction runs. This filters out the vast majority of messages. In a typical 1000-message day across channels, maybe 15 to 20 messages contain decision language. Only those trigger a thread fetch.

Second, a cooldown dictionary keyed by thread_ts prevents Meridian from re-processing the same thread more than once every five minutes. Without this, every subsequent message in a thread after a decision is made would trigger re-extraction. The cooldown means Meridian fires once per decision event, not once per follow-up reply.

A third guard: MIN_THREAD_MESSAGES = 3 in the thread processor. Single-message threads and two-message exchanges are ignored. A real decision thread has at least three messages.

Block Kit UI: The Single Palette Rule

All Block Kit structures are defined in listeners/views/blocks.py. No other file contains Block Kit JSON. This was a deliberate architectural choice because UI consistency is one of the judging criteria.

Every surface uses the same emoji set for status indicators: green circle for high confidence, yellow circle for medium, orange circle for low. Every card uses a divider between the content section and the action buttons. Every modal uses the same font weight and label style. Every home tab section uses the same header pattern.

The reason this matters for judges: inconsistency in UI signals "unfinished." A polished hackathon submission reads as intentional in every detail. Centralizing all Block Kit in one file makes it impossible to accidentally diverge.

The confirmation card has a specific anatomy designed to answer three questions in order:

  1. What did I decide? (the decision text in a blockquote)
  2. Should I trust this? (the confidence score with a colour-coded circle)
  3. What do I do next? (three clearly labelled action buttons)

The edit modal adds a fourth question: what if the extraction got it slightly wrong? The modal pre-fills the extracted text so the user can fix one word rather than retyping everything.

Testing Strategy: 45 Tests, No API Keys Required

The test suite is structured so that zero API keys are needed. This was a firm requirement: pytest tests/ -v must pass on a fresh clone with no environment setup.

The three test files cover three independent layers:

test_extractor.py tests the heuristic extraction logic directly. The core invariant test (test_deterministic_same_input_same_output) runs the same thread through the extractor twice and asserts that the confidence scores and due date strings are identical. This is the "provable, not just impressive" test: it proves the extractor is a pure function of its input, not a black box that might be faking results.

test_store.py tests both MemoryStore and SQLiteStore with the same test cases. SQLiteStore tests use a tempfile.NamedTemporaryFile for the database path and delete it in teardown. The most important test is test_persist_and_retrieve_decision which creates a SQLiteStore, saves a decision, creates a second SQLiteStore instance pointing to the same file, and reads the decision back. This proves durability, not just in-process consistency.

test_mcp_tools.py tests the MCP tool functions by monkeypatching the module-level store singleton before each test. Each test class gets a fresh seeded store. This design means the tools can be tested as plain Python functions without spinning up an actual MCP server, making them fast and deterministic. The test_invalid_status_returns_error test verifies that the tools return a structured error JSON rather than raising an unhandled exception, which is important for agent robustness.


Tech Stack

Layer Technology
Slack framework Bolt for Python 1.21.3
LLM extraction Anthropic Claude claude-3-5-haiku-20241022
MCP server FastMCP 3.4.4
Storage MemoryStore (default) or SQLiteStore
Canvas Slack Canvases API (canvases.create, canvases.access.set)
Search RTS API (assistant.search.context)
UI Block Kit (sections, actions, context, modals, home tab)
Transport Socket Mode (development) or HTTP (production)
Test runner pytest 8.3.5 with pytest-asyncio

Project Structure

meridian/
├── app.py                           # Bolt entry point, registers all listeners
├── manifest.yaml                    # Paste into api.slack.com to create app
├── pyproject.toml                   # Pinned dependencies
├── .env.example                     # Environment variable template
│
├── agent/
│   └── extractor.py                 # Decision + commitment extraction engine
│                                    # Claude path + heuristic fallback
│                                    # Deterministic confidence scoring
│
├── storage/
│   └── store.py                     # Decision and Commitment dataclasses
│                                    # MemoryStore: thread-safe in-memory
│                                    # SQLiteStore: persistent, same interface
│                                    # get_store() factory reads STORAGE_BACKEND
│
├── listeners/
│   ├── events/
│   │   ├── app_mentioned.py         # @Meridian handler
│   │   │                            # Question mode: local store + RTS API
│   │   │                            # Trigger mode: calls thread_processor
│   │   ├── message_listener.py      # Auto-detection
│   │   │                            # DECISION_RE pattern matching
│   │   │                            # 5-minute cooldown per thread_ts
│   │   ├── thread_processor.py      # Core extraction loop
│   │   │                            # conversations.replies fetch
│   │   │                            # extract_from_thread call
│   │   │                            # Card posting for each result
│   │   └── app_home_opened.py       # Home tab dashboard renderer
│   ├── actions/
│   │   ├── decision_actions.py      # Confirm: store update + Canvas creation
│   │   │                            # Edit: views.open modal
│   │   │                            # Dismiss: mark rejected
│   │   │                            # Modal submit: update + confirm
│   │   └── commitment_actions.py    # Mark Done: fulfill + card update
│   └── views/
│       └── blocks.py                # All Block Kit UI
│                                    # Confirmation card, commitment card
│                                    # Edit modal, Home Tab, Canvas markdown
│
├── mcp_server/
│   └── server.py                    # FastMCP server
│                                    # get_decisions, get_commitments
│                                    # get_decision_by_id, get_workspace_stats
│                                    # stdio transport (default) or HTTP
│
└── tests/
    ├── test_extractor.py            # 18 tests: heuristic patterns, due date
    │                                # parsing, determinism invariants
    ├── test_store.py                # 14 tests: CRUD, upsert, filtering,
    │                                # SQLite persistence across instances
    └── test_mcp_tools.py            # 13 tests: tool logic, error handling,
                                     # filter combinations, empty workspaces

Setup

Step 1: Create the Slack App

  1. Go to https://api.slack.com/apps
  2. Click "Create New App" then "From Manifest"
  3. Paste the contents of manifest.yaml
  4. Install the app to your workspace
  5. From "Basic Information", copy the Signing Secret
  6. From "OAuth and Permissions", copy the Bot User OAuth Token
  7. From "Basic Information" under App-Level Tokens, generate a token with connections:write scope

Step 2: Configure Environment

cp .env.example .env

Edit .env:

SLACK_BOT_TOKEN=xoxb-your-token-here
SLACK_APP_TOKEN=xapp-your-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
ANTHROPIC_API_KEY=sk-ant-your-key-here   # optional, enables Claude extraction

Step 3: Install and Run

python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

python app.py

Step 4: Invite Meridian to a Channel

/invite @Meridian

Step 5: Run the MCP Server (optional, for external agent queries)

# stdio mode (default, for Slack MCP client integration)
python mcp_server/server.py

# HTTP mode for remote clients
python mcp_server/server.py --http

Running Tests

pytest tests/ -v

All 45 tests run offline with no API keys required. The heuristic extractor is fully deterministic.

tests/test_extractor.py::TestHeuristicExtract::test_detects_decided_keyword PASSED
tests/test_extractor.py::TestHeuristicExtract::test_detects_lgtm PASSED
tests/test_extractor.py::TestHeuristicExtract::test_deterministic_same_input_same_output PASSED
...
tests/test_mcp_tools.py::TestGetDecisions::test_returns_only_confirmed PASSED
tests/test_mcp_tools.py::TestGetDecisions::test_filter_by_topic PASSED
...
tests/test_store.py::TestSQLiteStore::test_persist_and_retrieve_decision PASSED
...
45 passed in 0.85s

Environment Variables

Variable Required Description
SLACK_BOT_TOKEN Yes xoxb-... bot token from OAuth and Permissions
SLACK_APP_TOKEN Yes xapp-... app-level token for Socket Mode
SLACK_SIGNING_SECRET Yes From app Basic Information
ANTHROPIC_API_KEY No Enables Claude claude-3-5-haiku extraction. Falls back to heuristic if not set
STORAGE_BACKEND No memory (default) or sqlite for persistence across restarts
SQLITE_DB_PATH No Path to SQLite database file. Default: ./meridian.db
MCP_PORT No Port for HTTP MCP server mode. Default: 8765
LOG_LEVEL No DEBUG, INFO, WARNING, or ERROR. Default: INFO

Slack App Permissions

The manifest.yaml requests these scopes:

Bot token scopes:

  • chat:write, chat:write.public for posting messages and cards
  • app_mentions:read for detecting @Meridian
  • channels:history, groups:history, im:history, mpim:history for reading thread messages via conversations.replies
  • channels:read, groups:read for channel info and names
  • users:read for resolving user display names
  • reactions:add for the eyes reaction acknowledgement
  • canvases:write, canvases:read for Canvas document creation and sharing
  • search:read.public for the Real-Time Search API
  • team:read for workspace name in the Home tab

The Demo Script

This sequence proves the extraction is not hardcoded:

  1. Open a channel where Meridian is invited
  2. Start a thread with three casual messages. No decision language. Meridian is silent.
  3. Reply with: "Agreed, we're going with Postgres." The confirmation card appears with a confidence score.
  4. Click "Log It"
  5. Show the Canvas document that was created in the channel
  6. In a second terminal, query the MCP server directly:
python -c "
from mcp_server.server import get_decisions
import json
result = json.loads(get_decisions('YOUR_TEAM_ID'))
print(json.dumps(result, indent=2))
"
  1. Show the structured JSON output with the decision, confidence score, and canvas ID

Steps 2 and 3 together make the key point. The score changes because the thread changed, not because anything was configured in advance. The MCP query in step 6 shows that the data is machine-readable without going through Slack at all.


License

MIT

Built With

Share this project:

Updates