A chat interface over the warehouse from my Job Market Data Platform. You type something like “mid level data engineering jobs from the last day, remote only” and get back matching postings with links to apply. Follow-ups such as “only remote” or “anything over 150k” build on the filters you already have.
Try it here. The code is on GitHub.
The model picks filters, not SQL
The model is Llama 3.3 70B on Workers AI. It never writes a query. It reads the conversation and returns values for six parameters:
| Parameter | Values |
|---|---|
role_family | 15 families from the warehouse, such as Data engineering, Software engineering, DS / ML / AI |
seniority | intern, junior, mid, senior, staff, principal, leadership |
remote | Only remote-flagged, only not remote-flagged, or either |
posted_within | day, week, month or any |
min_salary | Published annual USD minimum |
limit | Number of results, default 10, hard maximum 20 |
The model returns a patch to the current filters, and a zod schema rejects anything outside those values. The patch is then checked against what the user actually typed. If the message says “data engineering” or “mid-level”, rules map those words to fixed values and override the model. On a follow-up like “only remote”, fields the user did not mention are dropped from the patch, so the model cannot quietly change the role or the date window while applying the new filter.
After that, application code runs one fixed, parameterized query against analytics.mart_job_search. The model sees the conversation and the current filters. It never sees credentials, SQL or job rows. The filters it chose are shown above the results as chips, so you can see how your request was read.
The database can only read one table
The Worker connects as a dedicated job_search_agent Postgres role with SELECT on the search mart and nothing else. A provisioning script creates or rotates the role and then proves it cannot read any other table in analytics or raw. The grant is declared in the dbt model itself, so it survives the table being rebuilt every day.
Memory
Each browser gets a random identity signed by the server, stored in an HttpOnly, same-site cookie. That identity maps to its own Durable Object, which keeps:
- up to 200 messages,
- the last accepted filters,
- and only the preferences the user explicitly asks it to remember.
Everything expires after 30 days of inactivity. The interface can clear the conversation, clear preferences, or delete everything. Write requests must come from the same origin.
Being honest about the data
The results are ordered by the most trustworthy date available. An employer’s publication date comes first. The pipeline’s own first_seen date is only used when it actually watched the posting appear.
The interface also says what it cannot know. Titles with no stated level are classed as mid-level and marked with an asterisk. “First observed” is labeled as when the scraper first saw a posting, not when it was published. A remote flag does not mean you can live anywhere, and the page says so.
Limits on everything
Request size, model calls, search time, number of results and retained history are all capped. Logs record the event type, elapsed time and result count, and never the prompt, the filters, database errors or credentials.
Tests
Vitest unit tests cover filter validation, the session layer and the UI. A second set runs the search against a Postgres fixture database, and an opt-in test runs a live read-only search against production.
What it does not do
Search uses the remote flag only. There are no city, state or country filters yet, even though the warehouse now has parsed locations to support them.