Coral’s Slack Connector Now Uses Table Functions: What Changed and Why
If you’ve been using Coral to query Slack data and noticed that slack.messages stopped working as a table, here's what happened. In Coral 0.3.0, channel messages and thread replies moved from filtered tables to table functions. This post covers exactly what changed, why the Coral team made this decision, and how to update your queries.

What Is Coral?
Coral is an open-source SQL runtime that lets you query APIs and databases as regular SQL tables. You connect a source (Slack, GitHub, Linear, Notion, etc.) and query it with standard SQL, locally, without any data leaving your machine.
What Changed in 0.3.0
Before 0.3.0, slack.messages was a table:
SELECT text, ts, user_id
FROM slack.messages
WHERE channel = 'C0123456789'
AND oldest = '1716000000'
AND latest = '1716400000'
That WHERE channel = ... filter was required. Without it, the query would fail or return nothing. But nothing in the schema told you that. It looked like a normal table you could SELECT * from freely.
Starting in 0.3.0, slack.messages and slack.thread_replies are table functions. The required arguments are part of the call signature:
SELECT text, ts, user_id
FROM slack.messages(channel => 'C0123456789')
slack.channels and slack.users are unchanged. They still work as regular tables with no required filters.
Why Table Functions?
The main driver was agent reliability.
When an agent sees slack.messages as a table, it writes SELECT * FROM slack.messages LIMIT 10. The query fails because channel is required but the schema doesn't say that anywhere. The agent backtracks, re-reads the schema hints, figures out the filter, and retries. That's 1 to 2 extra turns wasted on a completely avoidable error.
With a function signature, the argument is explicit in the schema. The agent sees slack.messages(channel => ...) and writes the correct query on the first attempt.
Small API change, real impact on agent accuracy.
Discovering Available Table Functions
To see all table functions registered in your Coral instance:
SELECT * FROM coral.table_functions
WHERE schema_name = 'slack'
To see all sources and what they expose:
SELECT schema_name, table_name
FROM coral.tables
The New Syntax
slack.messages: Channel Messages
SELECT ts, user_id, text, thread_ts, reply_count, subtype
FROM slack.messages(channel => 'C0123456789')
ORDER BY ts DESC
LIMIT 50
messages() Arguments
- channel (required): Slack channel ID, e.g. C0123456789
- oldest (optional): Return messages after this Unix timestamp, in seconds, as a string
- latest (optional): Return messages before this Unix timestamp, in seconds, as a string
messages() Columns
- user_id — Utf8, nullable: User ID of the message author
- text — Utf8, nullable: Message text content
- ts — Timestamp, not null: Message time as UTC Timestamp
- thread_ts — Utf8, nullable: Thread parent timestamp, null if not in a thread
- reply_count — Int64, nullable: Number of replies in thread
- subtype — Utf8, nullable: Message subtype, null for regular messages
ts is a proper Timestamp column now, not a float string. You can use it directly in comparisons and date functions without any casting.
slack.thread_replies: Thread Replies
SELECT ts, user_id, text
FROM slack.thread_replies(
channel => 'C0123456789',
thread_ts => '1716400000.000000'
)
ORDER BY ts ASC
The thread_ts value to pass here is the thread_ts column from a slack.messages row.
thread_replies() Arguments
- channel (required): Slack channel ID
- thread_ts (required): Parent message timestamp
- oldest (optional): Return replies after this Unix timestamp
- latest (optional): Return replies before this Unix timestamp
- inclusive (optional): Include messages at oldest/latest boundaries
- include_all_metadata (optional): Return full metadata on each reply
thread_replies() Columns
- thread_ts — Utf8, nullable: Thread parent timestamp
- user_id — Utf8, nullable: User ID of the reply author
- text — Utf8, nullable: Reply text content
- ts — Timestamp, not null: Reply time as UTC Timestamp
- reply_count — Int64, nullable: Number of replies in thread
- subtype — Utf8, nullable: Message subtype
Tables That Did Not Change
slack.channels and slack.users are still regular tables, no function call needed.
slack.channels
SELECT id, name, topic, purpose, num_members
FROM slack.channels
WHERE is_archived IS NOT TRUE
ORDER BY num_members DESC
channels Columns
- id — Utf8, not null: Channel ID
- name — Utf8, not null: Channel name
- topic — Utf8, nullable: Channel topic text
- purpose — Utf8, nullable: Channel purpose text
- num_members — Int64, nullable: Number of members
- is_archived — Boolean, not null: Whether the channel is archived
- created — Int64, not null: Unix timestamp when the channel was created
slack.users
SELECT id, name, real_name, email
FROM slack.users
WHERE is_bot IS NOT TRUE
AND deleted IS NOT TRUE
users Columns
- id — Utf8, not null: User ID
- name — Utf8, not null: Username
- real_name — Utf8, nullable: Full name
- display_name — Utf8, nullable: Display name in Slack
- email — Utf8, nullable: Email address (requires users:read.email scope)
- is_bot — Boolean, not null: Whether the user is a bot
- is_admin — Boolean, nullable: Whether the user is a workspace admin
- deleted — Boolean, nullable: Whether the account is deactivated
Required OAuth Scopes
Your Slack app needs these Bot Token scopes:
- channels:read: List public channels
- channels:history: Read messages in public channels
- groups:read: List private channels
- groups:history: Read messages in private channels
- users:read: List workspace users
- users:read.email: Read user email addresses (optional, needed for the email column)
Migrating Existing Queries
Before:
FROM slack.messages
WHERE channel = 'C0123456789'
AND oldest = '1716000000'
AND latest = '1716400000'
After:
FROM slack.messages(
channel => 'C0123456789',
oldest => '1716000000',
latest => '1716400000'
)
oldest and latest still work the same way: Unix timestamps in seconds, passed as strings. They're just function arguments now instead of WHERE filters.
If you were casting ts from a float in your old queries, remove that cast. ts is already a Timestamp:
Before:
ON to_timestamp(CAST(sl.ts AS DOUBLE)) >= CAST(pd.created_at AS TIMESTAMP)
After:
ON sl.ts >= CAST(pd.created_at AS TIMESTAMP)
Example: Messages in a Time Range
oldest and latest are passed directly to the Slack API as Unix timestamp strings. Compute them in your application and pass them as literals:
SELECT ts, user_id, text, reply_count
FROM slack.messages(
channel => 'C0123456789',
oldest => '1716000000',
latest => '1716800000'
)
WHERE subtype IS NULL
ORDER BY ts DESC
In Python:
import time
oldest = str(int(time.time()) - 7 * 24 * 60 * 60) # 7 days ago
latest = str(int(time.time()))
Example: Find Active Threads
Get messages that have replies, then fetch the thread:
-- Step 1: find messages with replies
SELECT ts, thread_ts, user_id, text, reply_count
FROM slack.messages(channel => 'C0123456789')
WHERE reply_count > 0
ORDER BY ts DESC
LIMIT 10;
-- Step 2: fetch a specific thread using the thread_ts value from above
SELECT ts, user_id, text
FROM slack.thread_replies(
channel => 'C0123456789',
thread_ts => '1716400000.000000'
)
ORDER BY ts ASC;
Summary of Changes
slack.messages: was a table with a required hidden WHERE filter, now a table function slack.messages(channel => '...')
slack.thread_replies: was a table with required hidden WHERE filters, now a table function slack.thread_replies(channel => '...', thread_ts => '...')
slack.channels: unchanged, still a regular table
slack.users: unchanged, still a regular table
ts column: was a float string (seconds since epoch), now a proper Timestamp (UTC)
If you have existing queries using the old table syntax, the migration is straightforward. Move the WHERE filters into function arguments and drop any to_timestamp(CAST(ts AS DOUBLE)) casts while you're at it.