Compare commits

...

157 Commits

Author SHA1 Message Date
Mihael Konjevic
e7f13e393b Update skills in the agent-runner folder 2026-03-09 17:04:40 +01:00
Mihael Konjevic
4f4ae5ff8c Agent cli improvements - MBQL 2026-03-09 17:04:40 +01:00
Mihael Konjevic
fbcea63a96 Initial agent implementation 2026-03-09 17:04:39 +01:00
Mihael Konjevic
d4b8d27f29 Split filters test 2026-03-09 17:04:39 +01:00
Mihael Konjevic
639c932359 Tools improvements 2026-03-09 17:04:39 +01:00
Mihael Konjevic
b9bffb1c1b Fix bugs and add tests 2026-03-09 17:04:38 +01:00
Mihael Konjevic
76d66f2873 enhance construct_notebook_query with compound filters, expressions, and fix post-filter indexing
- Add compound filter support (AND/OR) with nested filter groups
- Add inline nested expressions as arguments to expressions
- Add expression_ref support in aggregations (aggregate on calculated columns)
- Add advanced aggregations (median, stddev, var, percentile, cum-sum, cum-count, share)
- Add conditional aggregations (count-where, sum-where)
- Fix post-filter aggregation_index to correctly offset by number of breakouts
  (aggregation_index 0 now references first aggregation, not first breakout)
- Add filter-type and operator to enum-keys normalization
- Add regression tests for all new features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:04:38 +01:00
Oleksandr Solovyov
1f30d83dd8 fix api test 2026-03-09 17:04:38 +01:00
Oleksandr Solovyov
1248913b01 drop separate url for native agent, only have single url with a toggle 2026-03-09 17:04:37 +01:00
Oleksandr Solovyov
fa65962b5b Get tools back (#69901) 2026-03-09 17:04:37 +01:00
Oleksandr Solovyov
421813bc17 update path for e2e tests 2026-03-09 17:04:35 +01:00
Oleksandr Solovyov
86535670e1 define caps by metadata 2026-03-09 17:04:35 +01:00
Mihael Konjevic
ddaf5e6011 AI Service cleanup 2026-03-09 17:04:34 +01:00
Oleksandr Solovyov
e1313e4d7e fix modules config 2026-03-09 17:03:58 +02:00
Oleksandr Solovyov
0d2266c4d9 use embeddings proxy directly rather than via ai-service (#70419) 2026-03-09 16:31:49 +02:00
appleby
b2cbb49c87 BOT-1054 Add snowplow events for user intent classification (#70351)
* BOT-1054 Add snowplow events for user intent classification

* Move run-agent-loop :conversation-id into :tracking-opts :session-id

It does make some sense as a top-level argument, but it's currently only used for snowplow tracking, so push it down
into the tracking-opts.

* Move run-agent-loop :track-user-intent? into :tracking-opts

* Add ee-ai-metabot-provider-lite defsetting and use it in classify-user-intent!

This allows using a lighter weight model for simple tasks like user intent and/or agent response
classification. Defaults to openrouter/openai/gpt-oss-20b which is the same model used by ai-service for these tasks.

* Add run-agent-loop! test helper to run the agent for side effects

* whitespace: fix alignment

* Extract :tracking-opts schema into an mr/def similar to other agent opts

* Rename :profile-name -> :profile-id in tracking-opts
2026-03-09 16:31:49 +02:00
appleby
97478ecbd9 BOT-1053 Add snowplow events for agent tool calls (#70338)
* BOT-1039 Create metabase.analytics.util

And move some func there from metabase.analytics.llm-token-usage that will also be needed for agent_used_tool events.

* Add ai_service_event snowplow schema

In preparation for tracking the following events:

- agent_tool_used
- user_intent
- agent_action_summary

* Track snowplow agent_used_tool events in metabase-enterprise.metabot-v3.self/call-llm

- add agent loop :iteration to tracking-opts
- add :duration-ms to :tool-output parts
- add report-tool-usage-xf to transducer chain in metabase-enterprise.metabot-v3.self/call-llm to track
  :snowplow/ai_service_event with :event "agent_used_tool"
2026-03-09 16:31:49 +02:00
Maksym Yakubych
157ac541d4 fix: common library not used by agent 2026-03-09 16:31:49 +02:00
Maksym Yakubych
60557cf7f4 fix: add error field to transforms 2026-03-09 16:31:49 +02:00
Oleksandr Solovyov
5d553f13f8 drop unused tool find-outliers (#70424) 2026-03-09 16:31:49 +02:00
Maksym Yakubych
2be0b07ad5 fix: context not included in sql profile 2026-03-09 16:31:49 +02:00
appleby
1b3b446370 BOT-1039 Move token-usage funcs into analytics module (#70213)
* BOT-1052 Move token-usage funcs into analytics module

So they can be shared between oss sql gen and metabot.

* Add some snowplot tests for self/call-llm and call-llm-structured

* Add prometheus tests for call-llm-structured

* Add basic snowplow and prometheus tests for example question generation

* Modify agent loop snowplot test to use with-fake-snowplow-collector

* Modify native-generate-content-snowplow-test to use with-fake-snowplow-collector

* Fix whitespace
2026-03-09 16:31:49 +02:00
appleby
85df04918c BOT-1039 Add snowplow token_usage event tracking to the clojure agent (#70049)
* BOT-1039 Include the profile :name in the profile map in register-profile!

Sometimes it's useful to be able to exract the profile name when you only
have a reference to the profile.

* Add token_usage snowplow event tracking to the clojure agent

- refactor register-profile! to single-arg form with :name now stored in the profile map and returned by get-profile
- report-token-usage-xf now emits a :snowplow/token_usage event per LLM call alongside existing prometheus metrics
- pass tracking-opts from loop-step through call-llm into the token-usage transducer
- likewise for callers of call-llm-structured
- init-agent generates a per-run request-id UUID
- run-agent-loop accepts an optional conversation-id that turns into the snowplow token_usage session_id

* Add :model to tracking-opts and drop separate source arg

The prometheus source label should always be the same as the snowplow :tag (that's how it works in ai-service), so
drop the separate `source` arg from various functions and just take the source label from the tracking-opts :tag.

* Destructure token-opts in report-token-usage-xf

* Allow passing tracking-opts to run-agent-loop

And use this to override the :source in metabot-v3.api.document/native-generate-content

* Improve docstring for tracking-opts

* Provide model and source defaults in report-aisdk-errors-xf

* Fallback to analytics-uuid if premium-embedding-token is unset

* Fix typo: snowplot -> snowplow

* Compute hashed-token-or-uuid once rather than for each usage part in report-token-usage-xf
2026-03-09 16:31:49 +02:00
appleby
7722319837 BOT-1051 Fix provider in generate-example-questions (#70121)
After #70061 call-llm-structured now expects the model to include a
provider prefix, so "anthropic/claude-haiku-4-5" was getting parsed as
:provider "anthropic" :model "claude-haiku-4-5".

Update metabase-enterprise.metabot-v3.example-question-generator/call-llm to
use the provider + model configured via llm/ee-ai-metabot-provider
2026-03-09 16:31:49 +02:00
appleby
92126999df BOT-866 Add prometheus metrics to metabot clojure agent part II (#70094)
* BOT-866 Add prometheus metrics to metabot clojure agent

* BOT-866 Also count inline aisdk errors

* BOT-866 push prometheus token counting down into call-llm

This is more in keeping with how current ai-service reports the metric, i.e. per llm call rather than a single batched
token usage per agent request.

This also has the advantage of keeping most prometheus metrics localized to the llm layer.

* Ensure that :metabase-metabot/llm-requests counts retries

This is more consistent with the existing ai-service llm_invocations_total metric

* Make :metabase-metabot/llm-duration-ms per llm invocation rather than cumulative

This more closely matches the existing ai-service llm_duration_ms metric

* Add :metabase-metabot/agent-duration-ms prometheus metric

Records the full duration of the outer agent loop, now that :metabase-metabot/llm-duration-ms records individual llm calls.

* Add comment on :metabase-metabot/llm-duration-ms buckets

And extend the low end of the range down to 100ms

* fixup! Add :metabase-metabot/agent-duration-ms prometheus metric

* Fix whitespace

* Add prometheus metrics tracking to call-llm-structured

* Allow llm callers to specificy the source label for prometheus metrics

And pass "example-question-generation" for question generation.

* Fix call-llm-prometheus-test

After rebasing on #70061 these tests need to include the "openrouter/" provider prefix along with the model.
2026-03-09 16:31:48 +02:00
Oleksandr Solovyov
eff185561d llm model select (#70061) 2026-03-09 16:31:48 +02:00
Maksym Yakubych
312712414d feat: documents content generation for clojure metabot (#69807) 2026-03-09 16:31:48 +02:00
Oleksandr Solovyov
4e3f2ae330 prompt generation (#69970) 2026-03-09 16:31:48 +02:00
Oleksandr Solovyov
75538d9043 fix error message in custom-elements e2e 2026-03-09 16:31:48 +02:00
Oleksandr Solovyov
fb2879e655 drop separate url for native agent, only have single url with a toggle 2026-03-09 16:31:48 +02:00
Oleksandr Solovyov
b4134886aa Get tools back (#69901) 2026-03-09 16:31:48 +02:00
Oleksandr Solovyov
a1145cfa62 fix api test formatting 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
9ceadc771d fix usage in native streaming 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
3db23bb2f4 oh yeah very important type 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
3a4b3d8f86 fix remove-unreadable-transforms 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
b9291369c8 fix field_stats test 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
32c5546bf7 fix temporal checks 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
f55633438f test fixes 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
89457f666c get-dashboard-details 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
9eb3353b80 transforms in read_resource tool 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
3865ae0121 clj-kondo/cljfmt 2026-03-09 15:42:30 +02:00
Oleksandr Solovyov
0a6af96c82 cumulative usage from agent loop to reduce need for arithmetics 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
1c8f0a0f9d fix agent tests 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
4c6b4454b4 better schema for incoming messages 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
c6338a89df fix test for closing connections 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
785de90d84 review comments 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
685f4c5932 openrouter as main provider 2026-03-09 15:42:29 +02:00
Mihael Konjevic
04a5d55543 Fix issues with the sql profile 2026-03-09 15:42:29 +02:00
Mihael Konjevic
9dccceafc5 Replace raw/endraw tags with safe/endsafe 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
20cfbc41d9 make sure request cancellation works through with native agent streaming 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
e1ff254829 fix token undercounting 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
be383dc351 no more stacktraces on agent errors 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
d00c6eee63 great namespace movement 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
16a8000868 retry logic 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
295aa7c0c3 simpler date filters handling 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
390f9de00e more clear naming 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
3ad739cc4b fancy field select - reduces amount of tool calls needed 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
6b6e577d99 slight prompt rewording to bring it closer to what benchmarks measure 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
35e63b4100 instruct llms to use fq table names 2026-03-09 15:42:29 +02:00
Oleksandr Solovyov
a16e9434fe python has no header for tool instructions 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
b30954b198 fix claude usage tracking 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
a7688576be do not escape instructions 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
cd66872edf debug for python ai-service 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
428e655e26 accept both variations of db id 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
b0287a3367 unify query formatting 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
976071c2a0 fixes to improve llm behavior 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
00423c20c4 ability to debug llm queries 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
5e7a1448b7 handle inline llm errors 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
bdad323565 proper capability check 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
610dfa96e5 no :system-instructions 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
338f3a830e proper rendering for query data 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
08089fc951 put queries in the context 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
5140f984dc fix 'SQL Fixing' 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
f80a7e06cf fix construct rendering in system prompt 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
fed8768d5d good filters validation/errors to llm 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
23a4137f78 coerce tool args to json 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
f8eaf26a6a tool result should always have 'result' key 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
5a6065c83d report tool args well 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
7ea883b663 proper context instructions for sql tools 2026-03-09 15:42:28 +02:00
Oleksandr Solovyov
8cef495a82 sql dialect 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
56753fe2a0 little cleanups and field-metadata with instructions 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
6ad273fce7 improve search results tool response 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
62320a4090 more prompt changes 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
653d9b4993 fix fe formatting 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
5f768e8c7e fix read_resource 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
25020d85e3 make clj-kondo happy 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
79d8d7363d fix modules config 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
854de08118 datadog observability 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
22cb2eb1a9 system prompts 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
1fc3ae85fe collecting usage and schema on tool 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
5dac01a389 format tools to string at their definition 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
886c26f32a use simple templates more 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
02a09ee32d configurable api urls for llms 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
63d5473b04 cleanup agent.messages code/tests a bit 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
93abbca651 metabot error messages and tests 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
c2532b98e0 potentially: ability to reload repl state when js is compiled differently 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
b70859b99b better error messages from metabot backend 2026-03-09 15:42:27 +02:00
Oleksandr Solovyov
b600d31688 simpler tool executor function 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
5aece945ec make clj-kondo happier 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
f80ae41aa5 fix read resource output format 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
416852a407 test 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
68d932df4b cljfmt 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
bf2b2c9369 integration test 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
f5885009a4 cleanup user context templates 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
1c8ee758e2 cleaning up links code duplication 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
1a9bae0e69 update modules config 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
80b905eea9 output text fluidly to a user 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
0a66b38889 little things 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
157cd3968e update path for e2e tests 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
871df43d9c fix api test 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
8bd0ae2a2c cljfmt 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
6d0c7ce1ff damn 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
68af9d6e71 is this the last test 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
d7aa4d8447 clj-kondo woes 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
cbdf45cca3 fix profiles.clj 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
c7adc6c39a llm-repr test fixes 2026-03-09 15:42:26 +02:00
Oleksandr Solovyov
7c04cf83ee tools tests 2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
0a7c965278 api tests 2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
c4c62eea39 tools tests 2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
7e6596504f fix capabilities/nicer profiles 2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
b6c8797db2 ehhh sideeffect 2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
039fe45c4a clarify 2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
03b8a6d384 Refactor metabot v3 to use IReduceInit reducibles instead of core.async
- Replace channels with IReduceInit (reducible) pattern throughout
- Standard Clojure transducers work directly: (into [] xf (llm-call {...}))
- Remove core.async dependency from self namespace
- Rename chan? -> reducible?, sse-chan -> sse-reducible
- Rename tool-executor-rff -> tool-executor-xf
- Add malli schemas for agent state, messages, parts
- Simplify agent loop using reducible composition
- Update tests to use reducible patterns
2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
d6c6800859 factor out core.async as much as possible 2026-03-09 15:42:25 +02:00
Mihael Konjevic
0cbda9a877 Simplify metabot tool wrappers 2026-03-09 15:42:25 +02:00
Mihael Konjevic
232e389154 Route agent tools to core implementations 2026-03-09 15:42:25 +02:00
Mihael Konjevic
8a5892f291 Refactor metabot v3 tools namespace 2026-03-09 15:42:25 +02:00
Oleksandr Solovyov
74e18c1d91 define caps by metadata 2026-03-09 15:42:25 +02:00
Mihael Konjevic
276219c449 Refactor tool result formatting 2026-03-09 15:42:25 +02:00
Mihael Konjevic
2704ff16cd Render LLM representations with Selmer 2026-03-09 15:42:25 +02:00
Mihael Konjevic
896f1128ae Refactor metabot tool registration 2026-03-09 15:42:25 +02:00
Mihael Konjevic
ec85f6edc5 AI Service cleanup 2026-03-09 15:42:23 +02:00
Mihael Konjevic
f6b0e484bd Cleanup 2026-03-09 15:40:14 +02:00
Mihael Konjevic
9a06fc8bf3 Refactor SQL tools to use in-memory state and data parts
Key changes:
- SQL tools (edit_sql_query, replace_sql_query) now operate on in-memory
  query state instead of persisting to database
- Convert tools from reactions to data-parts pattern (navigate_to)
- Add semantic_queries and keyword_queries to search tool schema
- Make edit_sql_query and replace_sql_query state-dependent tools
- Support string query IDs (in addition to int) for memory-based queries
- Extract SQL dialect only from native query context type
- Use streaming.navigate-to-part instead of direct reactions
- Add instructions pattern to SQL tools for better LLM guidance

This aligns the SQL tools with the AI Service's transient query model
where queries exist in conversation state until explicitly saved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:14 +02:00
Mihael Konjevic
dde605fe55 Add streaming link buffer and improve AI service compatibility
Key changes:
- Add markdown_link_buffer.clj for handling links split across streaming
  text chunks (matches Python AI Service MarkdownLinkBuffer behavior)
- Seed agent state with queries from user_is_viewing context
- Support both snake_case and kebab-case keys in context for prompts
- Normalize entity types (keyword/string) in message formatting
- Decode tool arguments from JSON strings when needed
- Add table link resolution (metabase://table/{id})
- Preserve system-instructions when wrapping state-dependent tools
- Fix iteration boundary check (use inc iteration for comparison)
- Gracefully handle unresolvable links by falling back to link text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:14 +02:00
Mihael Konjevic
2a6378a759 Implement AI Service parity for native Clojure agent
This commit adds feature parity between the Clojure native agent and
Python AI service, implementing all phases from the parity plan.

New Tools:
- todo_write / todo_read: Task management with todo_list data parts
- navigate_user: Page and entity navigation with redirect reactions
- ask_for_sql_clarification: SQL clarification with final-response flag
- write_transform_sql / write_transform_python: Transform code editing
- create_autogenerated_dashboard: X-ray dashboard generation
- list_snippets / get_snippet_details: SQL snippet access

Core Changes:
- State data part format: {type: data, data-type: "state", version: 1}
- Finish message format: {type: finish, finish-reason: "stop"|"error"}
- New data part types: todo_list, code_edit, transform_suggestion
- Data part streaming from tool results via :data-parts key
- Final response pattern: tools return :final-response? true to stop loop
- Capability-based tool filtering with tool-capabilities map

Profile Updates:
- internal: Added navigate_user, create_autogenerated_dashboard
- transforms-codegen: Added todo tools, transform write tools, snippets
- sql-only: Added ask_for_sql_clarification, :required-tool-call? flag
- nlq-only: Added navigate_user, create_chart, edit_chart

Tests:
- Comprehensive tests for all new tools (29 new tests, 200+ assertions)
- Updated existing tests for new formats and capabilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:14 +02:00
Mihael Konjevic
874794fb03 Add auto-navigation and fix tool output handling for native agent
- Add redirect reactions to SQL query tools (create, edit, replace) for
  auto-navigation after query operations
- Add streaming module to convert reactions to AI SDK data parts
- Fix structured output extraction to handle both :structured-output and
  :structured_output key formats (Clojure vs JSON style)
- Add role normalization in message formatting to handle both keyword
  and string roles from frontend
- Include :query key in SQL tool returns for memory storage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
e34f33dc6a Align Clojure LLM representations exactly with Python AI Service
Updates tool output formatting to match Python AI Service patterns exactly:

- field->xml: Add quotes around name, database_type attr, ## Description header
- metric->xml: Add collection, default_time_dimension_field, metabase:// URIs
- table->xml: Add database_engine, fully_qualified_name, related_tables section
- model->xml: Use <metabase-model> tag, slug-based fqn, related_tables
- query->xml: Include database_id, query-content, query_results with rows
- Add question->xml, dashboard->xml, collection->xml, user->xml formats
- Add visualization->xml for full chart representation
- Add field-metadata->xml, get-metadata-result->xml for metadata results
- Update search result tags: model→metabase-model, card→metabase_question
- Update instructions.clj with complete Python-matching guidance text
- Update messages.clj to use <metabase-models> tag
- Expand tests to verify Python format compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
080e87a8b7 Add InstructionResultSchema pattern for native agent tool outputs
Align Clojure native agent tool outputs with Python AI Service patterns
by wrapping results in <result>/<instructions> XML format for consistent
LLM guidance.

- Add llm_representations.clj with entity-to-XML formatting utilities
- Add instructions.clj with LLM guidance constants for each tool type
- Update messages.clj to use format-with-instructions wrapper
- Add type-specific formatters for search, entity, query, chart results
- Include comprehensive tests for LLM representation functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
bd3d84099a Fix tool result formatting for LLM and link resolution
Key changes:
- Format tool results as XML for LLM consumption (messages.clj)
  - Query results now show query ID clearly: <query id="..." type="query">
  - Chart results include chart ID and reference link
  - This matches Python ai-service format so LLM can extract IDs properly

- Add fallback for chart link resolution (links.clj)
  - When LLM uses metabase://chart/ with a query ID, fall back to
    treating it as a query link
  - Handles common LLM mistake of using wrong link type

- Fix chart extraction logic (core.clj)
  - Charts identified by :chart-id + :query-id (without :query)
  - Distinguishes charts from query results correctly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
cc0ee1edb5 Add metabase:// link resolution for native agent streaming
The native agent now resolves metabase:// URIs in text parts before
streaming to clients. This mirrors the Python AI service's
MarkdownLinkBuffer behavior.

Link types supported:
- metabase://query/{uuid} - Resolves to /question# using stored query
- metabase://chart/{uuid} - Resolves to /question# via chart's query
- metabase://model/{id} - Resolves to /model/{id}
- metabase://metric/{id} - Resolves to /metric/{id}
- metabase://dashboard/{id} - Resolves to /dashboard/{id}
- metabase://question/{id} - Resolves to /question/{id}
- metabase://transform/{id} - Resolves to /admin/transforms/{id}

Changes:
- links.clj: New module for link resolution with markdown parsing
- core.clj: Integrate link processing in stream-parts-to-output!
- Tests for link resolution and streaming integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
5b8a1bc023 Add state management for query/chart IDs in native agent tools
State-dependent tools (show_results_to_user, create_chart, edit_chart) now
look up queries and charts from agent memory by ID instead of expecting raw
query objects. This mirrors the Python AI service pattern.

Key changes:
- memory.clj: Add query/chart storage functions (remember-query, find-query,
  store-chart, find-chart, load-queries-from-state, load-charts-from-state)
- core.clj: Extract queries/charts from tool outputs and store in memory
- tools.clj: Wrap state-dependent tools with memory atom access
- claude.clj: Update schema to accept wrapped tool maps with :fn/:doc/:schema
- self/core.clj: Handle wrapped tool maps in tool-executor-rff
- show_results_to_user.clj: Accept query-id and look up from queries-state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
68c8d44a34 Fix native agent loop for multi-turn conversations with tools
Key fixes:
- Fix premature loop exit: Continue when tool calls present, regardless of text
- Fix Claude API integration: Extract system message separately, pass via :system param
- Fix tool name mismatch: Pass tools as [name, var] pairs so Claude sees registry keys
- Fix message format for tool_use: Use content array format for assistant messages
- Handle START_STEP/FINISH_STEP types in aisdk->messages
- Rename profiles to simpler keys (:embedding, :internal, etc.)
- Add /native-agent-streaming endpoint for testing (bypasses feature flag)
- Add debug logging for message building

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
963b82b049 Add AI SDK v4 line protocol output format for native agent
The Python ai-service outputs a specific line protocol format that the
frontend expects. This adds the conversion layer to output the same format
from the native Clojure agent.

Format examples:
- 0:"text content"  - Text (JSON-encoded string)
- 2:{"type":...}    - Data parts
- 3:"error"         - Errors
- 9:{"toolCallId":..., "toolName":..., "args":...}  - Tool calls
- a:{"toolCallId":..., "result":...}  - Tool results
- d:{"finishReason":"stop", "usage":{...}}  - Finish message
- f:{"messageId":...}  - Start message

Changes:
- Add format-* functions for each part type in self/core.clj
- Add aisdk-line-xf transducer for batch conversion
- Export new functions via self.clj
- Update api.clj to stream parts in line protocol format
- Add comprehensive tests for all format functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
b7c9619a05 Implement Phase 2C chart tools, 2F profiles, and fix async blocking
Phase 2C - Chart Tools:
- Add create_chart tool to create visualizations from queries
- Add edit_chart tool to modify existing chart types
- Register chart tools in agent tools registry

Phase 2F - Profiles & Templates:
- Port sql-querying-only.selmer from Jinja2 (fixes broken :metabot-sql-only)
- Port metabot-next.selmer template
- Port natural-language-querying-only.selmer template
- Add :metabot-next, :metabot-nlq-only, :metabot-beta-customer profiles

Async Blocking Fix:
- Use a/thread instead of a/pipeline-blocking in run-llm-with-tools
- tool-executor-rff uses a/<!! in completion arity which cannot run in go-blocks
- Synchronous transducer application in thread context is now aligned with self code
- Fix stream-parts-to-output! and finalize-stream! to use a/>! in go blocks
- Fix api.clj syntax error with metadata on string literal
- Update tests to generate proper Claude raw SSE format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
5fa11593fa Add read_resource tool for fetching Metabase resources via URIs
Implement the foundational read_resource tool that provides unified
interface for fetching detailed information about Metabase resources
using URI patterns. This tool implements the "Context Engineering with
Links" pattern where URIs serve as lightweight, token-efficient
references that can be fetched on-demand at the appropriate detail level.

Supported URI patterns:
- metabase://table/{id} - Basic table info
- metabase://table/{id}/fields - Table structure with fields
- metabase://table/{id}/fields/{field_id} - Specific field details
- metabase://model/{id}/fields - Model structure with fields
- metabase://metric/{id}/dimensions - Metric dimensions
- metabase://transform/{id} - Transform details (not yet implemented)
- metabase://dashboard/{id} - Dashboard details (not yet implemented)

Key features:
- Parses metabase:// URIs and routes to appropriate handlers
- Supports fetching multiple resources in parallel (max 5)
- Handles field IDs with slashes (e.g., c75/17)
- Returns formatted XML-like output for LLM consumption
- Provides detailed error messages for invalid URIs

This tool is critical for:
- Enabling agents to get more context about resources on demand
- Supporting workflows where search returns URIs that agents fetch later
- Reducing token usage by deferring detail fetching until needed

Changes:
- Created read_resource.clj with URI parsing and resource handlers
- Added read-resource-tool wrapper in tools.clj with Malli schema
- Updated all 4 profiles to include read_resource tool
- Added comprehensive tests for URI parsing and resource fetching

Tests: 24 assertions, 0 failures

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
3c68e4abea Fix SQL tools to handle lib/query format
Fixed multiple issues with SQL tools not handling the transformed
lib/query format when Card models are read from the database:

## Issues Fixed

1. **extract-sql-content not finding SQL**: Card dataset_query transforms
   from {:native {:query "SQL"}} to {:stages [{:native "SQL"}]} when read.
   Updated extract-sql-content in all SQL tools to handle both formats.

2. **OffsetDateTime vs Date**: The rank-result function in sql_search
   called .getTime on timestamps, but Toucan2 returns OffsetDateTime.
   Added instanceof check to convert appropriately.

3. **Missing visualization_settings**: Test code using t2/insert! needed
   :visualization_settings {} field (database constraint).

4. **Test permissions**: Wrapped all test code with mt/with-current-user
   to provide proper permission context.

5. **Fixed typo in sql_search**: Changed accessible-cards variable reference
   from undefined to all-cards.

## Files Changed

- edit_sql_query.clj: Handle both lib/query and legacy formats
- replace_sql_query.clj: Handle both lib/query and legacy formats
- sql_search.clj: Handle both formats + OffsetDateTime conversion
- sql_tools_test.clj: Add visualization_settings, fix permissions,
  add clojure.string alias, fix indentation

## Test Results

All 19 assertions passing (4 test functions):
- edit-sql-query-test: 4 edit types tested
- replace-sql-query-test: Replace with metadata preservation
- sql-search-test: Search, filter by database, limit parameter
- integration-create-edit-search-test: Full workflow

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
57fc009ea2 Implement Phase 2B: SQL Query Tools
Add four SQL query manipulation tools to enable the agent to create, edit,
and search SQL queries:

New Tools:
- create_sql_query: Create new SQL queries with validation
- edit_sql_query: Apply structured edits (replace, append, insert, etc.)
- replace_sql_query: Replace SQL content entirely while preserving metadata
- sql_search: Search for SQL queries by content with relevance ranking

Features:
- Direct Toucan2 Card model access (no HTTP round-trips)
- Database and permissions validation
- Structured edit operations (replace, replace-all, append, prepend, insert-after, insert-before)
- Search with ranking by name match, description match, SQL position, and recency
- Integration with existing agent tool registry
- Added to metabot-internal profile and new metabot-sql-only profile

Testing:
- Comprehensive test coverage for all SQL tools
- Integration tests for full workflow (create → edit → search)
- All tests passing (18 assertions, 0 failures)

Architecture:
- Follows existing tool pattern (mu/defn with Malli schemas)
- Returns structured output for agent state tracking
- Error handling with agent-error? flag for LLM-friendly messages

Phase 2B complete. Ready for Phase 2C: Visualization Tools.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Mihael Konjevic
715072acc0 Fix async blocking issue in agent loop by using pipeline-blocking
The tool-executor-rff reducing function blocks in its completion arity
to wait for tool results. Using a/pipeline causes 'Invalid blocking call
in dispatch thread' errors because the regular pipeline uses a fixed
thread pool that doesn't allow blocking.

Solution: Use a/pipeline-blocking instead, which uses a separate thread
pool designed for blocking operations. This allows the tool executor's
completion arity to safely block while waiting for tool results.

Fixes the core-test integration test failures.
2026-03-09 15:40:13 +02:00
Mihael Konjevic
aba4de83e8 Add Selmer dependency for template rendering
Selmer v1.12.70 provides Jinja-like templating for Clojure,
used by the Phase 2A prompts system for rendering system messages
with dynamic context variables.
2026-03-09 15:40:13 +02:00
Mihael Konjevic
92a6dd2ab3 Integrate Phase 2A: Prompts & Context System into agent loop
This commit completes Phase 2A of the native Clojure agent implementation,
integrating the templated prompt system with context enrichment into the
main agent execution loop.

## New Features

### Prompt System (prompts.clj)
- Selmer template loading with caching
- SQL dialect instruction injection (14 dialects supported)
- Tool instruction extraction from docstrings
- System message template rendering with context variables

### Context Enrichment (user_context.clj)
- Timezone-aware time formatting
- SQL dialect extraction from context
- Viewing context formatting for all types:
  - Adhoc queries (notebook editor)
  - Native SQL queries with error context
  - Transforms with source information
  - Code editor with buffer/cursor/selection details
  - Entity details (tables, models, questions, metrics, dashboards)
- Recent views formatting with relevance hints
- Full context enrichment pipeline for template variable injection

### Resource Files
- 3 system prompt templates (embedding, internal, transform-codegen)
- 14 SQL dialect instruction files
- All templates support dynamic context injection

## Integration Changes

### Agent Core (core.clj)
- Updated build-messages-for-llm to accept context, profile, and tools
- Integrated system message building with enriched context
- Fixed pre-existing async blocking issue in run-llm-with-tools
  (changed from blocking a/<!! to returning channel for non-blocking a/<!)
- Agent now builds fresh system messages with context on each iteration

### Messages (messages.clj)
- Updated build-system-message to use templated prompts
- Accepts context, profile, and tools parameters
- Calls prompts/build-system-message-content for rendering

### Profiles (profiles.clj)
- Added :prompt-template mapping to all three profiles
- Maps profile IDs to template files in resources/metabot/prompts/system/

## Tests

All Phase 2A functionality is comprehensively tested:
- prompts-test: 6 tests, 46 assertions ✅
- user-context-test: 5 tests, 81 assertions ✅
- messages-test: 3 tests, 47 assertions ✅
- profiles-test: 1 test, 36 assertions ✅

Total: 15 tests, 210 assertions passing

Note: core-test integration tests have pre-existing failures due to blocking
calls in self/core.clj tool-executor-rff (line 116), which existed before
this change. The async pipeline architecture issue is tracked separately.

## Architecture

The agent now follows this flow on each iteration:
1. Enrich raw context (user_context/enrich-context-for-template)
2. Load and render system prompt template (prompts/build-system-message-content)
3. Build full message array with system message + history
4. Execute LLM call with enriched context
5. Process tool calls and responses
6. Repeat until completion

This matches the Python implementation's prompt and context system while
leveraging Clojure's functional architecture.
2026-03-09 15:40:13 +02:00
Mihael Konjevic
94be085bf5 Implement native Clojure agent for MetaBot v3
This commit implements a production-ready native Clojure agent loop that
replaces the external Python AI Service with an in-process implementation,
leveraging the existing llm-inside streaming infrastructure.

Key Components:
- agent/core.clj: Main agent loop with iteration control and streaming
- agent/memory.clj: Conversation state and step tracking
- agent/messages.clj: Message formatting and LLM communication
- agent/profiles.clj: Profile configurations (embedding, internal, transforms)
- agent/tools.clj: Tool registry with 13 wrapped tools

Integration:
- api.clj: Feature-flagged routing via use-native-agent setting
- settings.clj: New use-native-agent boolean setting (default: false)

Benefits:
- Direct Toucan2 database access (no HTTP round-trips)
- Single JVM process deployment
- Lower latency for tool execution
- Unified logging and error handling
- Reuses 80%+ of existing streaming infrastructure

Test Coverage:
- 5 test files with comprehensive coverage
- All tests passing (133 total assertions)
- Unit tests for memory, messages, profiles, tools
- Integration tests for agent loop with mocked LLM

The implementation is production-ready and can be enabled via feature flag
for gradual rollout and validation against the Python service.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-09 15:40:13 +02:00
Oleksandr Solovyov
e9f8a7d23c linter fixes 2026-03-09 15:40:13 +02:00
Oleksandr Solovyov
c6b3848f19 support for claude to show how different adapters are 2026-03-09 15:40:13 +02:00
Oleksandr Solovyov
a9a4640a26 switch to new openai /v1/responses api, check for structured output support 2026-03-09 15:40:12 +02:00
Oleksandr Solovyov
54e14d22bf Libor comments 2026-03-09 15:40:12 +02:00
Oleksandr Solovyov
419774dbb2 Experiment with communicating with LLMs in Clojure 2026-03-09 15:40:12 +02:00
256 changed files with 82238 additions and 1260 deletions

View File

@@ -0,0 +1,173 @@
---
name: clojure-eval
description: Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session.
---
# Clojure REPL Evaluation
## When to Use This Skill
Use this skill when you need to:
- **Verify that edited Clojure files compile and load correctly**
- Test function behavior interactively
- Check the current state of the REPL
- Debug code by evaluating expressions
- Require or load namespaces for testing
- Validate that code changes work before committing
## How It Works
The `clj-nrepl-eval` command evaluates Clojure code against an nREPL server. **Session state persists between evaluations**, so you can require a namespace in one evaluation and use it in subsequent calls. Each host:port combination maintains its own session file.
## Instructions
### 0. Discover and select nREPL server
First, discover what nREPL servers are running in the current directory:
```bash
clj-nrepl-eval --discover-ports
```
This will show all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) running in the current project directory.
**Then use the AskUserQuestion tool:**
- **If ports are discovered:** Prompt user to select which nREPL port to use:
- **question:** "Which nREPL port would you like to use?"
- **header:** "nREPL Port"
- **options:** Present each discovered port as an option with:
- **label:** The port number
- **description:** The server type and status (e.g., "Clojure nREPL server in current directory")
- Include up to 4 discovered ports as options
- The user can select "Other" to enter a custom port number
- **If no ports are discovered:** Prompt user how to start an nREPL server:
- **question:** "No nREPL servers found. How would you like to start one?"
- **header:** "Start nREPL"
- **options:**
- **label:** "deps.edn alias", **description:** "Find and use an nREPL alias in deps.edn"
- **label:** "Leiningen", **description:** "Start nREPL using 'lein repl'"
- The user can select "Other" for alternative methods or if they already have a server running on a specific port
IMPORTANT: IF you start a REPL do not supply a port let the nREPL start and return the port that it was started on.
### 1. Evaluate Clojure Code
> Evaluation automatically connects to the given port
Use the `-p` flag to specify the port and pass your Clojure code.
**Recommended: Pass code as a command-line argument:**
```bash
clj-nrepl-eval -p <PORT> "(+ 1 2 3)"
```
**For multiple expressions (single line):**
```bash
clj-nrepl-eval -p <PORT> "(def x 10) (+ x 20)"
```
**Alternative: Using heredoc (may require permission approval for multiline commands):**
```bash
clj-nrepl-eval -p <PORT> <<'EOF'
(def x 10)
(+ x 20)
EOF
```
**Alternative: Via stdin pipe:**
```bash
echo "(+ 1 2 3)" | clj-nrepl-eval -p <PORT>
```
### 2. Display nREPL Sessions
**Discover all nREPL servers in current directory:**
```bash
clj-nrepl-eval --discover-ports
```
Shows all running nREPL servers in the current project directory, including their type (clj/bb/basilisp) and whether they match the current working directory.
**Check previously connected sessions:**
```bash
clj-nrepl-eval --connected-ports
```
Shows only connections you have made before (appears after first evaluation on a port).
### 3. Common Patterns
**Require a namespace (always use :reload to pick up changes):**
```bash
clj-nrepl-eval -p <PORT> "(require '[my.namespace :as ns] :reload)"
```
**Test a function after requiring:**
```bash
clj-nrepl-eval -p <PORT> "(ns/my-function arg1 arg2)"
```
**Check if a file compiles:**
```bash
clj-nrepl-eval -p <PORT> "(require 'my.namespace :reload)"
```
**Multiple expressions:**
```bash
clj-nrepl-eval -p <PORT> "(def x 10) (* x 2) (+ x 5)"
```
**Complex multiline code (using heredoc):**
```bash
clj-nrepl-eval -p <PORT> <<'EOF'
(def x 10)
(* x 2)
(+ x 5)
EOF
```
*Note: Heredoc syntax may require permission approval.*
**With custom timeout (in milliseconds):**
```bash
clj-nrepl-eval -p <PORT> --timeout 5000 "(long-running-fn)"
```
**Reset the session (clears all state):**
```bash
clj-nrepl-eval -p <PORT> --reset-session
clj-nrepl-eval -p <PORT> --reset-session "(def x 1)"
```
## Available Options
- `-p, --port PORT` - nREPL port (required)
- `-H, --host HOST` - nREPL host (default: 127.0.0.1)
- `-t, --timeout MILLISECONDS` - Timeout (default: 120000 = 2 minutes)
- `-r, --reset-session` - Reset the persistent nREPL session
- `-c, --connected-ports` - List previously connected nREPL sessions
- `-d, --discover-ports` - Discover nREPL servers in current directory
- `-h, --help` - Show help message
## Important Notes
- **Prefer command-line arguments:** Pass code as quoted strings: `clj-nrepl-eval -p <PORT> "(+ 1 2 3)"` - works with existing permissions
- **Heredoc for complex code:** Use heredoc (`<<'EOF' ... EOF`) for truly multiline code, but note it may require permission approval
- **Sessions persist:** State (vars, namespaces, loaded libraries) persists across invocations until the nREPL server restarts or `--reset-session` is used
- **Automatic delimiter repair:** The tool automatically repairs missing or mismatched parentheses
- **Always use :reload:** When requiring namespaces, use `:reload` to pick up recent changes
- **Default timeout:** 2 minutes (120000ms) - increase for long-running operations
- **Input precedence:** Command-line arguments take precedence over stdin
## Typical Workflow
1. Discover nREPL servers: `clj-nrepl-eval --discover-ports`
2. Use **AskUserQuestion** tool to prompt user to select a port
3. Require namespace:
```bash
clj-nrepl-eval -p <PORT> "(require '[my.ns :as ns] :reload)"
```
4. Test function:
```bash
clj-nrepl-eval -p <PORT> "(ns/my-fn ...)"
```
5. Iterate: Make changes, re-require with `:reload`, test again

View File

@@ -796,7 +796,6 @@
lib
lib-be
models
premium-features
request
settings
sql-tools
@@ -2238,12 +2237,17 @@
enterprise/metabot-v3
{:team "Metabot"
:api #{metabase-enterprise.metabot-v3.api
metabase-enterprise.metabot-v3.client
metabase-enterprise.metabot-v3.core
metabase-enterprise.metabot-v3.init
metabase-enterprise.metabot-v3.tools.api}
:friends #{enterprise/agent-api}
metabase-enterprise.metabot-v3.tools.api
metabase-enterprise.metabot-v3.tools.deftool
metabase-enterprise.metabot-v3.tools.entity-details
metabase-enterprise.metabot-v3.tools.field-stats
metabase-enterprise.metabot-v3.tools.filters
metabase-enterprise.metabot-v3.tools.search
metabase-enterprise.metabot-v3.util}
:uses #{activity-feed
analytics
api
app-db
audit-app
@@ -2272,10 +2276,10 @@
task
transforms
transforms-base
users
util
warehouses
enterprise/dependencies
enterprise/llm
enterprise/transforms-python}}
enterprise/permission-debug
@@ -2377,8 +2381,7 @@
settings
task
util
enterprise/llm
enterprise/metabot-v3}}
enterprise/llm}}
enterprise/serialization
{:team "Querying"

View File

@@ -6,8 +6,10 @@ For detailed guidance on writing and reviewing code and documentation, see the s
## Clojure
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` shell commands.
- **[clojure-write](.claude/skills/clojure-write/SKILL.md)** - Clojure/ClojureScript development with REPL-driven workflow and coding conventions
- **[clojure-review](.claude/skills/clojure-review/SKILL.md)** - Clojure/ClojureScript code review guidelines and style enforcement
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Clojure code evaluation against running nREPL
## TypeScript

View File

@@ -0,0 +1,56 @@
# Common Patterns
## Setup
```bash
export METABASE_URL="http://localhost:3000"
export METABASE_API_KEY="mb_abc123..."
```
Or pass as flags: `--url`, `--api-key`, `--session-token`.
## Global Flags
| Flag | Effect |
|------|--------|
| `--fields id,name` | Only include these keys in response JSON |
| `--max-rows 200` | Override default 50-row cap on query results |
| `--dry-run` | Show API calls without executing |
| `--url <url>` | Metabase URL (overrides METABASE_URL) |
| `--api-key <key>` | API key (overrides METABASE_API_KEY) |
| `--session-token <token>` | Session token (overrides METABASE_SESSION_TOKEN) |
## JSON Payload Format
All mutation commands use `--json '<payload>'`.
Shell escaping for single quotes in SQL:
```bash
./metabase-agent create-question --json '{"sql": "SELECT * FROM t WHERE name = '"'"'test'"'"'"}'
```
## Error Handling
All errors are structured JSON on stderr:
```json
{
"error": "error_code",
"message": "Human-readable description",
"hint": "Suggested fix (when available)"
}
```
Common error codes: `missing_config`, `api_error`, `unknown_field`, `invalid_parameter`, `missing_parameter`, `unknown_filter`, `field_resolution`.
## Introspection
```bash
# List all commands with JSON schemas
./metabase-agent schema
# Get exact JSON Schema for any command
./metabase-agent schema create-dashboard
# Preview API calls without executing
./metabase-agent create-dashboard --dry-run --json '...'
```

View File

@@ -0,0 +1,35 @@
# Field Name Resolution
The CLI resolves human-readable field names to numeric Metabase field IDs automatically. You never need to look up field IDs manually.
## How It Works
1. You provide a field name string (e.g., `"created_at"` or `"Created At"`)
2. The CLI fetches table metadata via the API
3. It matches against both `name` (snake_case) and `display_name` (Title Case), case-insensitively
4. Table metadata is cached per session -- repeated lookups for the same table cost nothing
## Commands That Accept Field Names
- `create-segment` / `update-segment`: the `field` property in each filter
- `create-dashboard`: filter targets `field` property
- `add-card-to-dashboard`: filter_mappings `field` property
## Error Behavior
If a field name is not found, the error includes a closest-match suggestion and all available fields:
```json
{
"error": "unknown_field",
"message": "Field 'create_at' not found on table 5",
"hint": "Did you mean 'created_at'? Available fields: id, created_at, total, status",
"available_fields": ["id", "created_at", "total", "status"]
}
```
## Tips
- Run `get-table <id>` first to see all field names and types
- Prefer `name` (snake_case) over `display_name` -- it's more stable across locale changes
- Numeric string values like `"42"` pass through as numeric IDs without resolution

View File

@@ -0,0 +1,546 @@
# MBQL Query Construction
MBQL (Metabase Query Language) queries are constructed using Clojure code evaluated by the `construct-query` command. The code uses `metabase.lib` functions — the same library powering Metabase's visual query builder. Queries are correct by construction: if the code evaluates, the query is valid.
## Workflow
```bash
# 1. Discover schema (get table names and field names)
./metabase-agent get-table <table_id>
# 2. Construct and run MBQL query in one step
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
# OR: construct MBQL JSON, then use with other commands
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
# --> returns MBQL JSON to pass to create-question, execute-query, create-transform, or create-measure
```
## construct-query Command
```bash
# Return MBQL JSON
./metabase-agent construct-query --database-id <id> --clj '<code>'
# Construct AND execute in one step (returns query results)
./metabase-agent construct-query --database-id <id> --run --clj '<code>'
```
Without `--run`, returns legacy MBQL JSON that can be passed to:
- `create-question --json '{"query": <result>, ...}'`
- `execute-query --json '{"query": <result>, ...}'`
- `create-transform --json '{"query": <result>, ...}'`
- `create-measure --json '{"definition": <result>, ...}'`
With `--run`, executes the query and returns results directly (columns, rows, metadata).
## Helper Functions
### `table` — Look up table metadata
```clojure
(table "ORDERS") ; by name (case-insensitive)
(table 42) ; by numeric ID
```
Both string names and numeric IDs are accepted. Names are case-insensitive. On error, lists available tables with their IDs.
### `field` — Look up field metadata
```clojure
(field "ORDERS" "TOTAL") ; by table name + field name (case-insensitive)
(field "total") ; by field name only (searches all tables)
(field 123) ; by numeric ID
```
**Two-arity** `(field "TABLE" "FIELD")` is the explicit form — always unambiguous.
**Single-arity** `(field "name")` searches across all tables. If the field name exists in exactly one table, it resolves automatically. If ambiguous (exists in multiple tables), it throws an error listing which tables have that field.
**Numeric ID** `(field 123)` looks up by field ID directly.
### `query` — Create a new query
```clojure
(query (table "ORDERS")) ; pass table metadata from (table ...)
```
## Function Reference
All functions are available at the top level — no namespace prefix needed. The query is built using threading: `(-> (query ...) (filter ...) (aggregate ...) ...)`.
### Filtering (22 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `filter` | `(filter query clause)` | Add a filter clause to a query |
| `=` | `(= field value)` | Equal to |
| `!=` | `(!= field value)` | Not equal to |
| `<` | `(< field value)` | Less than |
| `<=` | `(<= field value)` | Less than or equal |
| `>` | `(> field value)` | Greater than |
| `>=` | `(>= field value)` | Greater than or equal |
| `between` | `(between field low high)` | Between two values (inclusive) |
| `contains` | `(contains field string)` | String contains |
| `does-not-contain` | `(does-not-contain field string)` | String does not contain |
| `starts-with` | `(starts-with field string)` | String starts with |
| `ends-with` | `(ends-with field string)` | String ends with |
| `is-null` | `(is-null field)` | Value is null |
| `not-null` | `(not-null field)` | Value is not null |
| `is-empty` | `(is-empty field)` | Empty string |
| `not-empty` | `(not-empty field)` | Not empty string |
| `in` | `(in field [v1 v2 ...])` | Value in set |
| `not-in` | `(not-in field [v1 v2 ...])` | Value not in set |
| `time-interval` | `(time-interval field n unit)` | Within time interval (e.g., last 30 days) |
| `and` | `(and clause1 clause2 ...)` | Logical AND of filter clauses |
| `or` | `(or clause1 clause2 ...)` | Logical OR of filter clauses |
| `not` | `(not clause)` | Negate a filter clause |
| `inside` | `(inside lat-field lon-field n s e w)` | Geographic bounding box filter |
#### Filtering Examples
```clojure
;; Simple equality filter
(-> (query (table "ORDERS"))
(filter (= (field "ORDERS" "STATUS") "completed")))
;; Compound filter with AND/OR
(-> (query (table "ORDERS"))
(filter (and (>= (field "ORDERS" "TOTAL") 100)
(or (= (field "ORDERS" "STATUS") "completed")
(= (field "ORDERS" "STATUS") "shipped")))))
;; Time interval: orders from the last 30 days
(-> (query (table "ORDERS"))
(filter (time-interval (field "ORDERS" "CREATED_AT") -30 :day)))
;; Null check
(-> (query (table "ORDERS"))
(filter (not-null (field "ORDERS" "DISCOUNT"))))
;; String matching
(-> (query (table "PRODUCTS"))
(filter (contains (field "PRODUCTS" "TITLE") "Widget")))
```
### Aggregation (17 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `aggregate` | `(aggregate query clause)` | Add an aggregation to a query |
| `count` | `(count)` | Count rows |
| `sum` | `(sum field)` | Sum of values |
| `avg` | `(avg field)` | Average |
| `min` | `(min field)` | Minimum |
| `max` | `(max field)` | Maximum |
| `distinct` | `(distinct field)` | Count distinct values |
| `median` | `(median field)` | Median value |
| `stddev` | `(stddev field)` | Standard deviation |
| `var` | `(var field)` | Variance |
| `percentile` | `(percentile field p)` | Percentile (p between 0 and 1) |
| `share` | `(share clause)` | Proportion of rows matching a filter |
| `count-where` | `(count-where clause)` | Count rows matching a filter |
| `sum-where` | `(sum-where field clause)` | Sum where filter matches |
| `distinct-where` | `(distinct-where field clause)` | Distinct count where filter matches |
| `cum-count` | `(cum-count)` | Cumulative count |
| `cum-sum` | `(cum-sum field)` | Cumulative sum |
#### Aggregation Result Column Names
When a query has aggregations, the result columns are auto-named by operator:
- First of each type: `count`, `sum`, `avg`, `min`, `max`, etc.
- Duplicates get a suffix: `sum_2`, `sum_3`, `count_2`, etc.
Use these names in `visualization.y_axis` when creating questions:
```json
{"visualization": {"y_axis": ["sum", "count"]}}
```
#### Aggregation Examples
```clojure
;; Simple count
(-> (query (table "ORDERS"))
(aggregate (count)))
;; Multiple aggregations (result columns: "sum", "count", "avg")
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(aggregate (count))
(aggregate (avg (field "ORDERS" "TOTAL"))))
;; Conditional aggregation: count of completed orders
(-> (query (table "ORDERS"))
(aggregate (count-where (= (field "ORDERS" "STATUS") "completed"))))
;; Percentage of completed orders
(-> (query (table "ORDERS"))
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
;; 95th percentile of order totals
(-> (query (table "ORDERS"))
(aggregate (percentile (field "ORDERS" "TOTAL") 0.95)))
```
### Breakout / Grouping (3 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `breakout` | `(breakout query field-or-expr)` | Group results by a column |
| `with-temporal-bucket` | `(with-temporal-bucket field bucket)` | Apply time bucketing to a column |
| `with-binning` | `(with-binning field strategy)` | Apply numeric binning |
**Temporal bucket values**: `:minute`, `:hour`, `:day`, `:week`, `:month`, `:quarter`, `:year`, `:minute-of-hour`, `:hour-of-day`, `:day-of-week`, `:day-of-month`, `:day-of-year`, `:week-of-year`, `:month-of-year`, `:quarter-of-year`
#### Breakout Examples
```clojure
;; Group by month
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
;; Group by multiple columns
(-> (query (table "ORDERS"))
(aggregate (count))
(breakout (field "ORDERS" "STATUS"))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :quarter)))
;; Group by day of week (for pattern analysis)
(-> (query (table "ORDERS"))
(aggregate (count))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :day-of-week)))
```
### Ordering and Limiting (4 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `order-by` | `(order-by query orderable)` | Sort ascending (default) |
| `order-by` | `(order-by query orderable :desc)` | Sort with explicit direction |
| `order-by` | `(order-by query :desc orderable)` | Direction-first syntax (also works) |
| `order-by` | `(order-by query (desc orderable))` | Using desc/asc wrappers |
| `asc` | `(asc orderable)` | Mark for ascending sort |
| `desc` | `(desc orderable)` | Mark for descending sort |
| `limit` | `(limit query n)` | Limit number of rows |
`order-by` accepts field metadata, aggregation clauses, and breakout columns. For aggregations (like `(sum ...)`), it automatically finds the matching orderable column.
#### Order/Limit Examples
```clojure
;; Top 10 orders by total, descending
(-> (query (table "ORDERS"))
(order-by (field "ORDERS" "TOTAL") :desc)
(limit 10))
;; Same thing using desc wrapper
(-> (query (table "ORDERS"))
(order-by (desc (field "ORDERS" "TOTAL")))
(limit 10))
;; Direction-first syntax (also valid)
(-> (query (table "ORDERS"))
(order-by :desc (field "ORDERS" "TOTAL"))
(limit 10))
;; Order by aggregation result — top categories by revenue
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "ORDERS" "STATUS"))
(order-by (desc (sum (field "ORDERS" "TOTAL")))))
;; Revenue by month, chronological order
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
(order-by (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month) :asc))
```
### Expressions (35+ functions)
Named expressions create computed columns. Wrap each in `(expression query "name" expr)`.
#### Arithmetic
| Function | Example |
|----------|---------|
| `+` | `(+ (field "T" "A") (field "T" "B"))` |
| `-` | `(- (field "T" "PRICE") (field "T" "COST"))` |
| `*` | `(* (field "T" "QTY") (field "T" "PRICE"))` |
| `/` | `(/ (field "T" "TOTAL") (field "T" "COUNT"))` |
#### Conditional
| Function | Signature | Description |
|----------|-----------|-------------|
| `case` | `(case [[cond1 val1] [cond2 val2] ...])` | Case expression with condition/value pairs |
| `coalesce` | `(coalesce field1 field2 ...)` | First non-null value |
#### String Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `concat` | `(concat str1 str2 ...)` | Concatenate strings |
| `substring` | `(substring field start length)` | Extract substring (1-indexed) |
| `replace` | `(replace field old new)` | Replace occurrences |
| `upper` | `(upper field)` | Uppercase |
| `lower` | `(lower field)` | Lowercase |
| `trim` | `(trim field)` | Trim whitespace |
| `ltrim` | `(ltrim field)` | Trim leading whitespace |
| `rtrim` | `(rtrim field)` | Trim trailing whitespace |
| `length` | `(length field)` | String length |
| `regex-match-first` | `(regex-match-first field pattern)` | First regex match |
#### Math Functions
| Function | Description |
|----------|-------------|
| `abs` | Absolute value |
| `ceil` | Ceiling |
| `floor` | Floor |
| `round` | Round |
| `power` | Power (`(power field exp)`) |
| `sqrt` | Square root |
| `log` | Natural logarithm |
| `exp` | e^x |
#### Temporal Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `now` | `(now)` | Current timestamp |
| `relative-datetime` | `(relative-datetime n unit)` | Relative time (e.g., -7 :day) |
| `absolute-datetime` | `(absolute-datetime str unit)` | Parse absolute datetime |
| `datetime-add` | `(datetime-add field n unit)` | Add time to datetime |
| `datetime-subtract` | `(datetime-subtract field n unit)` | Subtract time from datetime |
| `get-year` | `(get-year field)` | Extract year |
| `get-quarter` | `(get-quarter field)` | Extract quarter (1-4) |
| `get-month` | `(get-month field)` | Extract month (1-12) |
| `get-week` | `(get-week field)` | Extract week of year |
| `get-day` | `(get-day field)` | Extract day of month |
| `get-day-of-week` | `(get-day-of-week field)` | Extract day of week (1-7) |
| `get-hour` | `(get-hour field)` | Extract hour (0-23) |
| `get-minute` | `(get-minute field)` | Extract minute (0-59) |
| `get-second` | `(get-second field)` | Extract second (0-59) |
| `convert-timezone` | `(convert-timezone field target source)` | Convert timezone |
| `relative-time-interval` | `(relative-time-interval field n unit offset-n offset-unit)` | Relative time interval filter |
#### Advanced
| Function | Signature | Description |
|----------|-----------|-------------|
| `expression` | `(expression query "name" expr)` | Define a named expression (computed column) |
| `offset` | `(offset expr n)` | Access value from N rows before/after |
| `segment` | `(segment id)` | Reference a saved segment by ID |
#### Expression Examples
```clojure
;; Computed column: profit margin
(-> (query (table "PRODUCTS"))
(expression "profit_margin"
(/ (- (field "PRODUCTS" "PRICE") (field "PRODUCTS" "COST"))
(field "PRODUCTS" "PRICE"))))
;; Case expression: order size category
(-> (query (table "ORDERS"))
(expression "size_category"
(case [[(< (field "ORDERS" "TOTAL") 50) "Small"]
[(< (field "ORDERS" "TOTAL") 200) "Medium"]]
{:default "Large"})))
;; Date math: days since order
(-> (query (table "ORDERS"))
(expression "days_since_order"
(datetime-subtract (now) (field "ORDERS" "CREATED_AT") :day)))
;; Previous month comparison using offset
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
(expression "prev_month_revenue" (offset (sum (field "ORDERS" "TOTAL")) -1)))
```
### Joins (9 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `join` | `(join query join-clause)` | Add a join to a query |
| `join-clause` | `(join-clause table-meta)` | Create a join clause for a table |
| `with-join-conditions` | `(with-join-conditions clause [cond ...])` | Set join conditions |
| `with-join-strategy` | `(with-join-strategy clause strategy)` | Set join type |
| `with-join-fields` | `(with-join-fields clause :all/:none)` | Include joined fields |
| `with-join-alias` | `(with-join-alias clause alias)` | Set join alias |
| `suggested-join-conditions` | `(suggested-join-conditions query table)` | Auto-detect join conditions |
| `joinable-columns` | `(joinable-columns query join-clause)` | Get columns available for join conditions |
| `joins` | `(joins query)` | Get existing joins on a query |
**Join strategies**: `:left-join` (default), `:right-join`, `:inner-join`
#### Join Examples
```clojure
;; Join orders with products
(let [q (query (table "ORDERS"))
products (table "PRODUCTS")]
(-> q
(join (-> (join-clause products)
(with-join-conditions
[(= (field "ORDERS" "PRODUCT_ID")
(field "PRODUCTS" "ID"))])
(with-join-fields :all)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "PRODUCTS" "CATEGORY"))))
;; Inner join with alias
(let [q (query (table "ORDERS"))
users (table "PEOPLE")]
(-> q
(join (-> (join-clause users)
(with-join-conditions
[(= (field "ORDERS" "USER_ID")
(field "PEOPLE" "ID"))])
(with-join-strategy :inner-join)
(with-join-fields :all)))
(filter (= (field "PEOPLE" "STATE") "CA"))
(aggregate (count))))
```
### Column Introspection (6 functions)
These functions return available columns at a given query stage. Useful for dynamic resolution.
| Function | Description |
|----------|-------------|
| `visible-columns` | All visible columns in the current stage |
| `filterable-columns` | Columns that can be used in filters |
| `breakoutable-columns` | Columns that can be used for grouping |
| `aggregable-columns` | Columns that can be aggregated |
| `orderable-columns` | Columns that can be used for sorting |
| `expressionable-columns` | Columns available for use in expressions |
### Query Lifecycle
| Function | Description |
|----------|-------------|
| `->legacy-MBQL` | Convert a pMBQL query to legacy MBQL format (done automatically by construct-query) |
## Complete Examples
### Revenue by Month (Line Chart)
```clojure
(-> (query (table "ORDERS"))
(filter (= (field "ORDERS" "STATUS") "completed"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
```
### Top Categories by Revenue (Bar Chart)
```clojure
(let [q (query (table "ORDERS"))
products (table "PRODUCTS")]
(-> q
(join (-> (join-clause products)
(with-join-conditions
[(= (field "ORDERS" "PRODUCT_ID")
(field "PRODUCTS" "ID"))])
(with-join-fields :all)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "PRODUCTS" "CATEGORY"))
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
(limit 10)))
```
### Conversion Rate (Scalar)
```clojure
(-> (query (table "ORDERS"))
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
```
### Orders with Computed Columns
```clojure
(-> (query (table "ORDERS"))
(expression "discount_pct"
(/ (field "ORDERS" "DISCOUNT") (field "ORDERS" "SUBTOTAL")))
(expression "is_large_order"
(case [[(>= (field "ORDERS" "TOTAL") 200) "Yes"]]
{:default "No"}))
(filter (not-null (field "ORDERS" "DISCOUNT")))
(order-by (desc (field "ORDERS" "TOTAL")))
(limit 50))
```
### Using let for Readable Multi-Table Queries
```clojure
(let [orders (table "ORDERS")
products (table "PRODUCTS")
people (table "PEOPLE")
q (query orders)]
(-> q
(join (-> (join-clause products)
(with-join-conditions
[(= (field "ORDERS" "PRODUCT_ID")
(field "PRODUCTS" "ID"))])
(with-join-fields :all)))
(join (-> (join-clause people)
(with-join-conditions
[(= (field "ORDERS" "USER_ID")
(field "PEOPLE" "ID"))])
(with-join-fields :all)))
(filter (and (= (field "PEOPLE" "STATE") "CA")
(time-interval (field "ORDERS" "CREATED_AT") -90 :day)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(aggregate (count))
(breakout (field "PRODUCTS" "CATEGORY"))))
```
### Top 10 Customers by Lifetime Value
```clojure
(let [q (query (table "ORDERS"))
people (table "PEOPLE")]
(-> q
(join (-> (join-clause people)
(with-join-conditions
[(= (field "ORDERS" "USER_ID")
(field "PEOPLE" "ID"))])
(with-join-fields :all)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(aggregate (count))
(breakout (field "PEOPLE" "NAME"))
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
(limit 10)))
```
## Gotchas
- **`field` with two args**: `(field "TABLE" "FIELD")` — always works, even with joins.
- **`field` with one arg**: `(field "FIELD")` — searches all tables. Throws if ambiguous (field exists in multiple tables). Use the two-arg form to disambiguate.
- **`table` and `field` accept IDs**: `(table 42)` and `(field 123)` work with numeric IDs from discovery APIs.
- **Table/field names are case-insensitive**: `(field "orders" "total")` and `(field "ORDERS" "TOTAL")` are equivalent.
- **Multiple `aggregate` calls chain**: Each `(aggregate ...)` adds one aggregation. Don't pass multiple aggregations in a single call.
- **Aggregation result column names**: `sum`, `count`, `avg`, etc. Duplicates get `_2`, `_3` suffixes. Use these names in `visualization.y_axis`.
- **Ordering by aggregations**: Use `(order-by query (desc (sum (field ...))))` — the aggregation clause is auto-matched to the query's orderable columns.
- **`--run` flag**: Use `construct-query --run` to construct and execute in one step, avoiding two-step shell piping.
- **Direction in `order-by`**: All three syntaxes work: `(order-by q field :desc)`, `(order-by q :desc field)`, `(order-by q (desc field))`.
## Clojure Syntax Quick Reference
The SCI evaluator supports standard Clojure syntax:
| Form | Example | Description |
|------|---------|-------------|
| `->` | `(-> x (f) (g))` | Thread-first macro: `(g (f x))` |
| `let` | `(let [x 1] x)` | Local bindings |
| `fn` | `(fn [x] (* x 2))` | Anonymous function |
| `if` | `(if test then else)` | Conditional |
| Keywords | `:month`, `:desc` | Used for options/enums |
| Vectors | `[1 2 3]` | Used for lists of values |
| Maps | `{:key "value"}` | Key-value pairs |
| Strings | `"text"` | Double-quoted strings |

View File

@@ -0,0 +1,61 @@
# Visualization Types and Settings
## Display Types
| Type | Use for | Key settings |
|------|---------|-------------|
| `table` | Detailed data (default) | none needed |
| `line` | Time series, trends | x_axis, y_axis |
| `bar` | Comparisons, distributions | x_axis, y_axis, stack |
| `area` | Cumulative, stacked trends | x_axis, y_axis, stack |
| `combo` | Mixed chart types | x_axis, y_axis |
| `scatter` | Correlations | x_axis, y_axis |
| `waterfall` | Sequential changes | x_axis, y_axis |
| `pie` | Part-of-whole | dimension, metric |
| `row` | Horizontal bar chart | x_axis, y_axis |
| `scalar` | Single KPI number | none |
| `smartscalar` | KPI with trend | none |
| `gauge` | Progress toward target | none |
| `progress` | Progress bar | none |
| `pivot` | Cross-tabulations | x_axis, y_axis |
| `funnel` | Conversion funnels | x_axis, y_axis |
| `map` | Geographic data | map_type, map_region |
| `boxplot` | Statistical distribution | x_axis, y_axis |
| `sankey` | Flow diagrams | x_axis, y_axis |
## Settings Object
All settings are optional. The CLI translates these to Metabase's internal format.
| CLI key | Metabase key | Type | Notes |
|---------|-------------|------|-------|
| `x_axis` | `graph.dimensions` | string[] | X axis columns |
| `y_axis` | `graph.metrics` | string[] | Y axis columns |
| `stack` | `stackable.stack_type` | `"stacked"` / `"normalized"` | bar/area |
| `show_values` | `graph.show_values` | boolean | data labels |
| `x_axis_label` | `graph.x_axis.title_text` | string | axis title |
| `y_axis_label` | `graph.y_axis.title_text` | string | axis title |
| `line_style` | `line.style` | `"solid"` / `"dashed"` / `"dotted"` | |
| `line_interpolation` | `line.interpolate` | `"linear"` / `"cardinal"` / `"monotone"` | |
| `dimension` | `pie.dimension` | string[] | pie categories |
| `metric` | `pie.metric` | string | pie value column |
| `show_legend` | `pie.show_legend` | boolean | |
| `map_type` | `map.type` | `"pin"` / `"region"` / `"grid"` | |
| `map_region` | `map.region` | string | |
## Column Name Matching
`x_axis` and `y_axis` values must match SQL column aliases exactly:
```sql
SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1
```
Correct: `"x_axis": ["month"], "y_axis": ["revenue"]`
Wrong: `"x_axis": ["created_at"], "y_axis": ["total"]`
## Chart-Specific Notes
- **pie**: Use `dimension` and `metric` instead of `x_axis`/`y_axis`
- **scalar/smartscalar**: SQL should return one row, one column. No viz settings needed.
- **gauge/progress**: SQL should return a single numeric value.
- **pivot**: `x_axis` columns become row headers, `y_axis` is the aggregated value.

View File

@@ -0,0 +1,180 @@
---
name: building-dashboards
description: Creates and manages Metabase dashboards with card placement, auto-layout, and filter wiring. Use when building dashboards, placing question cards on grids, or connecting dashboard filters to card fields.
---
# Building Dashboards
@./../_shared/field-resolution.md
## create-dashboard
Create a dashboard with cards and filters in one step.
```bash
./metabase-agent create-dashboard --json '<payload>'
```
Full JSON template:
```json
{
"name": "Sales Dashboard",
"description": "Q4 sales overview",
"collection_id": 7,
"cards": [
{"card_id": 42, "width": 12, "height": 6},
{"card_id": 43, "width": 6, "height": 4},
{"card_id": 44, "width": 6, "height": 4}
],
"filters": [
{
"name": "Date Range",
"type": "date/range",
"targets": [
{"card_id": 42, "field": "created_at"},
{"card_id": 43, "field": "order_date"}
]
}
]
}
```
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `name` | yes | | Dashboard name |
| `description` | no | | Dashboard description |
| `collection_id` | no | | Collection to save in |
| `cards` | no | | Cards to place (see layout below) |
| `cards[].card_id` | yes | | Question/card ID from `create-question` |
| `cards[].width` | no | 6 | Width in grid units (max 24) |
| `cards[].height` | no | 4 | Height in grid units |
| `cards[].row` | no | auto | Row position |
| `cards[].col` | no | auto | Column position |
| `filters` | no | | Dashboard-level filters |
| `filters[].name` | yes | | Label shown on dashboard |
| `filters[].type` | yes | | Filter widget type (see table below) |
| `filters[].targets` | yes | | Cards and fields this filter connects to |
Internally chains POST (create dashboard) + PUT (add cards and filters) in one command.
Output:
```json
{"id": 15, "name": "Sales Dashboard", "cards_added": 3, "filters_added": 1}
```
## Auto-Layout (24-Column Grid)
Cards are placed left-to-right, wrapping to the next row when a card exceeds column 24.
If `row` and `col` are both specified, those exact positions are used. Otherwise, auto-layout applies.
Common layout patterns:
```json
// Full width
{"card_id": 1, "width": 24, "height": 6}
// Two equal columns
{"card_id": 2, "width": 12, "height": 4},
{"card_id": 3, "width": 12, "height": 4}
// Three equal columns
{"card_id": 4, "width": 8, "height": 4},
{"card_id": 5, "width": 8, "height": 4},
{"card_id": 6, "width": 8, "height": 4}
// Four equal columns (KPI row)
{"card_id": 7, "width": 6, "height": 3},
{"card_id": 8, "width": 6, "height": 3},
{"card_id": 9, "width": 6, "height": 3},
{"card_id": 10, "width": 6, "height": 3}
// One wide + one narrow
{"card_id": 11, "width": 16, "height": 6},
{"card_id": 12, "width": 8, "height": 6}
```
## Dashboard Filter Types
| type | Widget | Use for |
|------|--------|---------|
| `date/range` | Date range picker | Date columns |
| `date/single` | Single date picker | Exact date |
| `date/month-year` | Month picker | Monthly filtering |
| `date/quarter-year` | Quarter picker | Quarterly filtering |
| `date/relative` | Relative date | Dynamic ranges (last 30 days) |
| `category` | Dropdown | String/enum columns |
| `id` | ID input | Numeric ID columns |
| `number` | Number input | Numeric columns |
| `string` | Text input | Free text |
| `string/contains` | Text (contains) | Partial match |
| `string/starts-with` | Text (starts-with) | Prefix match |
Filter targets use field names that are resolved automatically (see field-resolution.md).
## add-card-to-dashboard
Add a card to an existing dashboard with optional filter wiring.
```bash
./metabase-agent add-card-to-dashboard --json '<payload>'
```
```json
{
"dashboard_id": 15,
"card_id": 50,
"width": 6,
"height": 4,
"filter_mappings": [
{"filter_name": "Date Range", "field": "created_at"}
]
}
```
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `dashboard_id` | yes | | Dashboard ID |
| `card_id` | yes | | Card/question ID to add |
| `width` | no | 6 | Width in grid units |
| `height` | no | 4 | Height in grid units |
| `filter_mappings` | no | | Wire to existing dashboard filters |
| `filter_mappings[].filter_name` | yes | | Must match existing filter name (case-insensitive) |
| `filter_mappings[].field` | yes | | Field name on this card to wire to |
New card is placed at the bottom of the dashboard below all existing cards.
Output:
```json
{"dashboard_id": 15, "card_id": 50, "position": {"row": 8, "col": 0}}
```
## get-dashboard
```bash
./metabase-agent get-dashboard <id>
```
Output:
```json
{
"id": 15,
"name": "Sales Dashboard",
"description": "Q4 overview",
"parameters": [{"id": "abc12345", "name": "Date Range", "type": "date/range"}],
"cards": [
{"dashcard_id": 100, "card_id": 42, "card_name": "Revenue by Month",
"size_x": 12, "size_y": 6, "row": 0, "col": 0}
]
}
```
Use this to see current layout before adding cards or to verify dashboard creation.
## Gotchas
- `card_id` is the `id` from `create-question`, NOT the `dashcard_id` shown in `get-dashboard`
- For SQL cards, filter wiring requires the card to have been run at least once (so `result_metadata` exists). Run the question in Metabase UI or use `execute-query` first.
- `add-card-to-dashboard` reads existing cards and PUTs the entire array back. It handles this correctly -- you just provide the new card.
- Filter `targets` use field names, not IDs. The field must exist on the card's source table or SQL result columns.
- Card `width` must be between 1 and 24. Common widths: 6 (quarter), 8 (third), 12 (half), 16 (two-thirds), 24 (full).
- Mixing auto-layout (no row/col) with explicit positions: auto-layout cards fill in array order. For predictable results, use all-auto or all-explicit.

View File

@@ -0,0 +1,435 @@
---
name: creating-questions
description: Creates saved questions with visualizations, reusable filter segments, measures, metrics, and runs ad-hoc MBQL or SQL queries in Metabase. Use when saving queries as named questions with charts, defining reusable filters/aggregations, or running exploratory queries.
---
# Creating Questions, Segments, Measures, and Queries
@./../_shared/visualization-settings.md
@./../_shared/field-resolution.md
@./../_shared/mbql-construction.md
## construct-query
Construct an MBQL query by evaluating Clojure code with `metabase.lib` functions. Returns legacy MBQL JSON or (with `--run`) executes the query directly.
```bash
# Return MBQL JSON
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
# Construct AND execute in one step
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
```
Example:
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(filter (= (field "ORDERS" "STATUS") "completed"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
```
Returns a JSON object like:
```json
{"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ...]], ...}}
```
With `--run`, returns query results (columns, rows, metadata) instead of MBQL JSON.
See the MBQL Construction reference for all available functions and examples.
## create-question
Create a saved question (card) with visualization settings. **Always prefer MBQL** — Metabase can do much more with structured queries (drill-down, auto-filters, column-level metadata, cross-database compatibility). Use SQL only when MBQL can't express the query.
```bash
./metabase-agent create-question --json '<payload>'
```
### MBQL question (structured query — preferred)
First construct the MBQL query with `construct-query`, then use the output as the `query` field:
```json
{
"name": "Revenue by Month",
"database_id": 1,
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]], "breakout": [["field", 20, {"temporal-unit": "month"}]]},
"display": "line",
"visualization": {
"x_axis": ["CREATED_AT"],
"y_axis": ["sum"]
}
}
```
### SQL question (native query — use only when MBQL can't express the query)
```json
{
"name": "Revenue by Month",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
"display": "line",
"type": "question",
"visualization": {
"x_axis": ["month"],
"y_axis": ["revenue"],
"y_axis_label": "Revenue ($)",
"show_values": true
},
"collection_id": 7,
"description": "Monthly revenue trend"
}
```
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `name` | yes | | Question name |
| `database_id` | yes | | Database ID |
| `sql` | one of sql/query | | SQL query (native question) |
| `query` | one of sql/query | | MBQL query object from `construct-query` |
| `display` | no | `"table"` | Chart type (see visualization-settings.md) |
| `type` | no | `"question"` | `"question"`, `"model"`, or `"metric"` |
| `visualization` | no | | Chart settings (see visualization-settings.md) |
| `collection_id` | no | | Save to specific collection |
| `description` | no | | Question description |
Exactly one of `sql` or `query` must be provided.
Output:
```json
{"id": 42, "name": "Revenue by Month", "display": "line", "type": "question"}
```
The returned `id` is the `card_id` you pass to dashboard commands.
### Example: Line chart (MBQL — recommended)
Two-step workflow: first construct the MBQL, then create the question.
```bash
# Step 1: Construct MBQL query
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# --> returns {"database": 1, "type": "query", "query": {...}}
# Step 2: Create question with the MBQL output as the "query" field
./metabase-agent create-question --json '{
"name": "Revenue Trend",
"database_id": 1,
"query": <paste output from step 1>,
"display": "line",
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
}'
```
For MBQL questions, `visualization.y_axis` uses the aggregation operator name (`sum`, `count`, `avg`, etc.), not SQL aliases.
### Example: Top 10 bar chart (MBQL with ordering)
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "ORDERS" "STATUS"))
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
(limit 10))'
```
### Example: Pie chart (MBQL)
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (count))
(breakout (field "ORDERS" "STATUS")))'
./metabase-agent create-question --json '{
"name": "Orders by Status",
"database_id": 1,
"query": <paste output>,
"display": "pie",
"visualization": {"dimension": ["STATUS"], "metric": "count"}
}'
```
### Example: KPI scalar (MBQL)
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL"))))'
./metabase-agent create-question --json '{
"name": "Total Revenue",
"database_id": 1,
"query": <paste output>,
"display": "scalar"
}'
```
### Example: Metric (reusable aggregation on a card)
Metrics are cards with `type: "metric"`. They define a reusable aggregation:
```bash
./metabase-agent create-question --json '{
"name": "Total Revenue",
"database_id": 1,
"type": "metric",
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]},
"display": "scalar",
"description": "Sum of all order totals"
}'
```
### Example: SQL fallback (use only when MBQL can't express the query)
```bash
./metabase-agent create-question --json '{
"name": "Revenue Trend",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
"display": "line",
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
}'
```
## update-question
```bash
./metabase-agent update-question <id> --json '<payload>'
```
All fields are optional. If updating `sql`, you MUST include `database_id`.
```json
{
"name": "Updated Name",
"display": "bar",
"sql": "SELECT ...",
"database_id": 1,
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"], "stack": "stacked"},
"description": "Updated description"
}
```
## create-segment
Create a reusable filter (segment) on a table using field names.
```bash
./metabase-agent create-segment --json '<payload>'
```
Full JSON template:
```json
{
"name": "Active Users",
"table_id": 5,
"filters": [
{"field": "last_login", "op": "greater-than", "value": "2024-01-01"},
{"field": "status", "op": "equals", "value": "active"}
],
"description": "Users active in 2024"
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Segment name |
| `table_id` | yes | Table ID |
| `filters` | yes (min 1) | Filter conditions using field names |
| `description` | no | Segment description |
Multiple filters are ANDed together automatically. Field names are resolved to IDs via the table's metadata (see field-resolution.md).
### Filter Operations
| Operation | Description | Needs value? |
|-----------|-------------|-------------|
| `equals` | Exact match | yes |
| `not-equals` | Not equal | yes |
| `greater-than` | > | yes |
| `greater-than-or-equal` | >= | yes |
| `less-than` | < | yes |
| `less-than-or-equal` | <= | yes |
| `contains` | String contains | yes |
| `not-contains` | Does not contain | yes |
| `starts-with` | String starts with | yes |
| `ends-with` | String ends with | yes |
| `is-null` | Value is null | no |
| `is-not-null` | Value is not null | no |
| `is-empty` | Empty string | no |
| `is-not-empty` | Not empty string | no |
Use `values` (array) instead of `value` for multi-value matching.
Output:
```json
{"id": 10, "name": "Active Users", "table_id": 5}
```
## update-segment
```bash
./metabase-agent update-segment <id> --json '<payload>'
```
```json
{
"name": "Updated Name",
"description": "New description",
"table_id": 5,
"filters": [{"field": "status", "op": "equals", "value": "active"}],
"revision_message": "Updated filter criteria"
}
```
If updating `filters`, you MUST include `table_id`. `revision_message` defaults to "Updated via CLI".
## create-measure
Create a reusable aggregation expression (measure) tied to a table. Measures use MBQL definitions with exactly one aggregation.
```bash
./metabase-agent create-measure --json '<payload>'
```
```json
{
"name": "Total Revenue",
"table_id": 5,
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]}},
"description": "Sum of all order totals"
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Measure name |
| `table_id` | yes | Table ID this measure belongs to |
| `definition` | yes | MBQL5 query object (from `construct-query`) |
| `description` | no | Measure description |
Workflow: Use `construct-query` to build the MBQL, then pass the output as `definition`.
Output:
```json
{"id": 3, "name": "Total Revenue", "table_id": 5}
```
## list-measures
```bash
./metabase-agent list-measures
```
Output:
```json
[{"id": 3, "name": "Total Revenue", "table_id": 5, "description": "Sum of all order totals"}]
```
## get-measure
```bash
./metabase-agent get-measure <id>
```
## update-measure
```bash
./metabase-agent update-measure <id> --json '<payload>'
```
```json
{
"name": "Updated Name",
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["avg", ["field", 23, null]]]}},
"revision_message": "Changed from sum to avg"
}
```
`revision_message` defaults to "Updated via CLI".
## execute-query
Run ad-hoc queries without saving. Prefer `construct-query --run` for MBQL — it's simpler. Use `execute-query` when you already have a raw query object or need SQL.
```bash
./metabase-agent execute-query --json '<payload>'
```
### MBQL execution (preferred — or use `construct-query --run` instead)
```json
{
"database_id": 1,
"query": {"source-table": 5, "aggregation": [["count"]], "filter": ["=", ["field", 50, null], "completed"]}
}
```
### SQL execution (use only when MBQL can't express the query)
```json
{
"database_id": 1,
"sql": "SELECT COUNT(*) AS total_orders FROM orders WHERE status = 'completed'"
}
```
Exactly one of `sql` or `query` must be provided.
Output:
```json
{
"status": "completed",
"columns": [{"name": "total_orders", "display_name": "Total Orders", "base_type": "type/Integer"}],
"rows": [[1523]],
"row_count": 1,
"running_time": 245,
"_meta": {"truncated": false, "total_count": 1, "returned_count": 1}
}
```
Default max 50 rows. Override with `--max-rows 200`. Results are ephemeral -- use `create-question` to save.
## run-question
Run a saved question and return sample results.
```bash
./metabase-agent run-question <id>
./metabase-agent run-question <id> --max-rows 20
```
Default: 10 rows. Use this to verify a question works before adding it to a dashboard, or to inspect the data shape.
Output:
```json
{"card_id": 42, "status": "completed",
"columns": [{"name": "month", "display_name": "Month", "base_type": "type/DateTime"},
{"name": "revenue", "display_name": "Revenue", "base_type": "type/Float"}],
"rows": [["2024-01-01T00:00:00Z", 12345.67]],
"row_count": 12, "running_time": 150,
"_meta": {"truncated": true, "total_count": 12, "returned_count": 10}}
```
## Gotchas
- **Always use MBQL** unless the query requires SQL-specific features (CTEs, window functions, database-specific syntax, etc.). MBQL questions support drill-down, auto-filters, and cross-database compatibility that SQL questions cannot provide.
- `create-question` returns `id` -- this is the `card_id` for dashboard commands
- **MBQL y_axis**: Use aggregation operator names: `"y_axis": ["sum"]`, `"y_axis": ["sum", "count"]`. Duplicates get suffixes: `sum_2`, `sum_3`
- **SQL y_axis**: Column aliases must match `x_axis`/`y_axis` exactly: `SELECT SUM(total) AS revenue` means `"y_axis": ["revenue"]`
- For pie charts, use `dimension` and `metric`, not `x_axis`/`y_axis`
- `type: "model"` creates a Metabase model (queryable like a table)
- `type: "metric"` creates a reusable metric aggregation
- Segment field names are resolved against table metadata. Run `get-table <table_id>` first to confirm field names.
- `execute-query` does NOT save anything. Use `create-question` for persistent results.
- MBQL queries from `construct-query` include the full `dataset_query` structure -- pass the entire output as the `query` field.
- Measures require MBQL definitions -- use `construct-query` to build them.
- Use `construct-query --run` to test MBQL queries ad-hoc without creating a saved question.

View File

@@ -0,0 +1,170 @@
---
name: discovering-data
description: Discovers databases, tables, fields, and existing content in Metabase. Use when exploring data sources, understanding schema, or searching for existing dashboards and questions before building new analytics.
---
# Discovering Data
Always start here before creating transforms, questions, or dashboards.
@./../_shared/common-patterns.md
## list-databases
List all databases, optionally including their tables.
```bash
# Compact list
./metabase-agent list-databases --fields id,name,engine
# Include table names (recommended first step)
./metabase-agent list-databases --include tables
```
Output shape:
```json
[{"id": 1, "name": "Production DB", "engine": "postgres",
"tables": [{"id": 5, "name": "orders", "schema": "public"}]}]
```
This gives you `database_id` and `table_id` values needed by all other commands.
## get-database
Full database metadata with every table and field.
```bash
./metabase-agent get-database 1
```
Output shape:
```json
{"id": 1, "name": "Production DB", "engine": "postgres",
"tables": [{"id": 5, "name": "orders", "schema": "public", "display_name": "Orders",
"fields": [{"id": 42, "name": "id", "display_name": "ID",
"base_type": "type/Integer", "semantic_type": "type/PK"}]}]}
```
Use when you need field-level detail for all tables in one call. Output can be large for big databases -- prefer `get-table` for specific tables.
## get-table
Single table with all fields, types, and foreign keys.
```bash
./metabase-agent get-table 5
```
Output shape:
```json
{"id": 5, "name": "orders", "display_name": "Orders", "schema": "public",
"db_id": 1, "description": "...",
"fields": [{"id": 42, "name": "created_at", "display_name": "Created At",
"base_type": "type/DateTime", "semantic_type": null,
"fk_target_field_id": null}]}
```
Key field properties:
- `base_type`: `type/Integer`, `type/Text`, `type/DateTime`, `type/Float`, `type/Boolean`
- `semantic_type`: `type/FK` (foreign key), `type/PK` (primary key), `type/Email`, etc.
- `fk_target_field_id`: non-null for foreign keys, points to the target field
This command tells you what field names to use in segments, questions, and dashboard filter targets.
## search
Free-text search across all Metabase content.
```bash
./metabase-agent search "revenue" --models table,card,dashboard
```
`--models` values: `table`, `dashboard`, `card`, `metric`, `segment`
Output shape:
```json
{"results": [{"id": 5, "name": "Revenue Report", "model": "card",
"description": "...", "database_id": 1, "table_id": 5}],
"_meta": {"total_count": 3}}
```
Use to find existing content before creating duplicates.
## get-field
Get full field metadata including statistical fingerprint.
```bash
./metabase-agent get-field 42
```
Output shape:
```json
{"id": 42, "name": "total", "display_name": "Total", "table_id": 5,
"base_type": "type/Float", "semantic_type": null,
"has_field_values": "none",
"fingerprint": {
"distinct_count": 200, "null_percent": 0.0,
"min": 12.5, "max": 1250.0, "avg": 89.43, "q1": 35.0, "q3": 120.0, "sd": 75.2
}}
```
Fingerprint content depends on field type:
- **Numbers**: `min`, `max`, `avg`, `q1`, `q3`, `sd` (standard deviation), `distinct_count`, `null_percent`
- **Dates**: `earliest`, `latest`, `distinct_count`, `null_percent`
- **Text**: `average_length`, `distinct_count`, `null_percent`
Use to understand value ranges before building filters (e.g., knowing the min/max of a price field).
## get-field-values
Get distinct values stored in a field. Essential for building correct filters.
```bash
./metabase-agent get-field-values 50
```
Output shape:
```json
{"field_id": 50, "values": ["cancelled", "completed", "pending", "shipped"], "has_more_values": false}
```
Use this to discover valid filter values. For example, before writing `(filter (= (field "ORDERS" "STATUS") "completed"))`, run `get-field-values` on the status field to see all possible values.
Notes:
- Only works for fields with `has_field_values: "list"` (typically low-cardinality fields like status, category, etc.)
- For high-cardinality fields (IDs, timestamps), returns empty values — use `get-field` fingerprint instead for range info
- If a field has remapped display values, returns `[original_value, display_name]` pairs
## ping
Health check. No arguments.
```bash
./metabase-agent ping
```
## Discovery Workflow
```
1. ./metabase-agent list-databases --include tables --fields id,name
--> Pick database_id, note table names
2. ./metabase-agent get-table <table_id>
--> Learn field names, types, relationships
3. ./metabase-agent get-field-values <field_id>
--> Discover valid values for categorical fields (status, type, category)
4. ./metabase-agent get-field <field_id>
--> Check value ranges for numeric/date fields (min, max, avg, date range)
5. ./metabase-agent search "keyword" --models card,dashboard
--> Check if the analysis already exists
```
## Gotchas
- `get-database` returns ALL tables. For large databases (100+ tables), use `list-databases --include tables` then `get-table` for specific tables.
- Search results are capped server-side. Use `--models` to narrow results.
- Table IDs, database IDs, and field IDs are stable. You can reference them across commands.

View File

@@ -0,0 +1,164 @@
---
name: metabase-agent-workflow
description: Orchestrates the Metabase agent CLI workflow for building analytics from raw data. Use when the goal involves discovering databases, creating transforms, defining questions or segments, or building dashboards.
---
# Metabase Agent Workflow
Build analytics from raw data in four phases:
```
1. Discover --> 2. Model --> 3. Define --> 4. Visualize
```
| Phase | What it does | What it produces |
|-------|-------------|-----------------|
| Discover | Explore databases, tables, fields | database_id, table_id, field names |
| Model | Clean/join/aggregate data with MBQL or SQL transforms | New tables in the database |
| Define | Save MBQL (preferred) or SQL as questions with charts; create segments, measures, metrics | card_id values for dashboards |
| Visualize | Build dashboards with cards and filters | Stakeholder-ready dashboards |
## Command Reference
| Goal | Command | Phase |
|------|---------|-------|
| Find databases and tables | `list-databases --include tables` | Discover |
| Inspect table fields and types | `get-table <id>` | Discover |
| Search existing content | `search "query" --models table,card` | Discover |
| Full database schema | `get-database <id>` | Discover |
| Field metadata + fingerprint | `get-field <id>` | Discover |
| Distinct values for a field | `get-field-values <id>` | Discover |
| Create MBQL/SQL transform | `create-transform --json '{...}'` | Model |
| Inspect transform | `get-transform <id>` | Model |
| List transforms with status | `list-transforms` | Model |
| Check transform run | `get-transform-run <run_id>` | Model |
| Construct MBQL query | `construct-query --database-id <id> --clj '<code>'` | Define |
| Construct + run MBQL | `construct-query --database-id <id> --run --clj '<code>'` | Define |
| Save MBQL/SQL as a chart | `create-question --json '{...}'` | Define |
| Create reusable filter | `create-segment --json '{...}'` | Define |
| Create reusable aggregation | `create-measure --json '{...}'` | Define |
| List all measures | `list-measures` | Define |
| Get measure details | `get-measure <id>` | Define |
| Update a measure | `update-measure <id> --json '{...}'` | Define |
| Run ad-hoc MBQL or SQL | `execute-query --json '{...}'` | Define |
| Run saved question | `run-question <id>` | Define |
| Build dashboard with cards | `create-dashboard --json '{...}'` | Visualize |
| Add card to dashboard | `add-card-to-dashboard --json '{...}'` | Visualize |
| View dashboard layout | `get-dashboard <id>` | Visualize |
## Essential Rules
- **Always use MBQL** for queries. MBQL questions support drill-down, auto-filters, column-level metadata, and cross-database compatibility that SQL questions cannot provide. Use SQL only when MBQL genuinely can't express the query (e.g., CTEs, window functions, database-specific syntax).
- All mutations use `--json '<payload>'` with simplified JSON (not raw API format)
- Field names (like `"created_at"`) are resolved to numeric IDs automatically -- never look up IDs manually
- Queries use MBQL constructed via `construct-query` (preferred) or native SQL (fallback)
- `construct-query --run` lets you construct and execute MBQL in one step (no need to pipe through `execute-query`)
- In MBQL, `table` and `field` accept both string names and numeric IDs: `(table "ORDERS")` or `(table 42)`
- `field` with one argument searches all tables: `(field "total")` — use two args if ambiguous
- Use `--dry-run` before mutations to preview API calls
- Use `--fields id,name` to keep responses compact
- Use `./metabase-agent schema <command>` to see exact JSON Schema for any command
## End-to-End Example (MBQL — recommended)
```bash
# 1. Discover: find the database and inspect tables
./metabase-agent list-databases --include tables --fields id,name
./metabase-agent get-table 5
# 2. Explore: test an MBQL query ad-hoc (construct + execute in one step)
./metabase-agent construct-query --database-id 1 --run --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# --> returns query results (columns, rows)
# 3. Define: construct MBQL (without --run) to get the query JSON
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# --> returns {"database": 1, "type": "query", "query": {...}}
# 4. Define: create question with MBQL result
./metabase-agent create-question --json '{
"name": "Revenue by Month",
"database_id": 1,
"query": <output from construct-query>,
"display": "line",
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
}'
# 5. Define: create a reusable measure
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL"))))'
./metabase-agent create-measure --json '{
"name": "Total Revenue",
"table_id": 5,
"definition": <output from construct-query>
}'
# 6. Visualize: build a dashboard
./metabase-agent create-dashboard --json '{
"name": "Sales Dashboard",
"cards": [{"card_id": 42, "width": 24, "height": 6}]
}'
```
## End-to-End Example (SQL — use only when MBQL can't express the query)
```bash
# 1. Discover: find the database and tables
./metabase-agent list-databases --include tables --fields id,name
# 2. Discover: inspect the orders table
./metabase-agent get-table 5
# 3. Define: create a chart question
./metabase-agent create-question --json '{
"name": "Revenue by Month",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
"display": "line",
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
}'
# --> returns {"id": 42, ...}
# 3b. Verify: run the question and check sample results
./metabase-agent run-question 42
# 4. Visualize: build a dashboard
./metabase-agent create-dashboard --json '{
"name": "Sales Dashboard",
"cards": [{"card_id": 42, "width": 24, "height": 6}],
"filters": [{"name": "Date", "type": "date/range",
"targets": [{"card_id": 42, "field": "month"}]}]
}'
```
## Checklist
```
- [ ] Discover: identify database_id and table_ids
- [ ] Discover: inspect field names and types with get-table
- [ ] Discover: check field values (get-field-values) for filter fields
- [ ] (Optional) Model: create-transform if raw data needs cleaning
- [ ] Define: construct-query for MBQL (preferred) — use SQL only if MBQL can't express it
- [ ] Define: create-question for each visualization needed
- [ ] Define: (optional) create-segment for reusable filters
- [ ] Define: (optional) create-measure for reusable aggregations
- [ ] Define: (optional) create metric with type: "metric"
- [ ] Visualize: create-dashboard with card_ids from above
- [ ] Visualize: wire dashboard filters to card fields
```
## Detailed Skill References
For complete JSON formats, examples, and gotchas for each command group:
- [Discovering Data](./../discovering-data/SKILL.md) -- databases, tables, search
- [Modeling Transforms](./../modeling-transforms/SKILL.md) -- MBQL/SQL transforms
- [Creating Questions](./../creating-questions/SKILL.md) -- questions, segments, measures, queries, MBQL construction
- [Building Dashboards](./../building-dashboards/SKILL.md) -- dashboards, cards, filters
- [Common Patterns](./../_shared/common-patterns.md) -- setup, global flags, errors

View File

@@ -0,0 +1,195 @@
---
name: modeling-transforms
description: Creates and manages MBQL or SQL transforms that materialize query results into database tables. Use when raw data needs cleaning, joining, or restructuring before building questions and dashboards.
---
# Modeling Transforms
Transforms run MBQL (preferred) or SQL queries and write the results to a new database table. Use them when raw tables need cleaning, joining, or aggregation before downstream analysis. Use SQL only when MBQL can't express the query.
Do NOT use transforms for: one-off queries (use `execute-query`) or saved questions that just need a chart (use `create-question`).
@./../_shared/mbql-construction.md
## create-transform
```bash
./metabase-agent create-transform --json '<payload>'
```
### MBQL transform (preferred)
First use `construct-query` to build the MBQL query, then pass the output as the `query` field:
```json
{
"name": "Clean Orders",
"database_id": 1,
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
"target_table": "clean_orders",
"target_schema": "transforms",
"description": "Orders excluding cancelled",
"run": true
}
```
### SQL transform (use only when MBQL can't express the query)
```json
{
"name": "Clean Orders",
"database_id": 1,
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
"target_table": "clean_orders",
"target_schema": "transforms",
"target_database_id": 2,
"description": "Orders excluding cancelled",
"run": true
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Transform name |
| `database_id` | yes | Source database ID |
| `sql` | one of sql/query | SQL query for the transform |
| `query` | one of sql/query | MBQL query object from `construct-query` |
| `target_table` | yes | Output table name |
| `target_schema` | no | Schema for output table |
| `target_database_id` | no | Defaults to `database_id` |
| `description` | no | Description |
| `run` | no | Execute immediately after creation |
Exactly one of `sql` or `query` must be provided.
### Example: MBQL transform (recommended)
```bash
# 1. Construct the MBQL query
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# 2. Use the output as the query field
./metabase-agent create-transform --json '{
"name": "Monthly Revenue",
"database_id": 1,
"query": <output from construct-query>,
"target_table": "monthly_revenue",
"target_schema": "transforms",
"run": true
}'
```
### Example: SQL transform (fallback)
```bash
./metabase-agent create-transform --json '{
"name": "Monthly Revenue",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1",
"target_table": "monthly_revenue",
"target_schema": "transforms",
"run": true
}'
```
Output when `run: true`:
```json
{"transform": {"id": 15, "name": "Monthly Revenue"},
"run": {"status": "succeeded", "run_id": 123}}
```
When `run: true`, the CLI polls until completion (up to 5 minutes) or returns `polling_timeout`.
Output when `run: false` or omitted:
```json
{"id": 15, "name": "Monthly Revenue", "source_type": "query"}
```
## get-transform
Get a transform's full definition including its query, target table, and last run status.
```bash
./metabase-agent get-transform <id>
```
Output:
```json
{
"id": 15,
"name": "Clean Orders",
"description": "Orders excluding cancelled",
"database_id": 1,
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
"target_table": "clean_orders",
"target_schema": "transforms",
"last_run_status": "succeeded",
"last_run_at": "2024-12-01T10:30:00Z"
}
```
Use this to inspect an existing transform's query before updating it.
## list-transforms
```bash
./metabase-agent list-transforms
```
Output:
```json
[{"id": 15, "name": "Clean Orders", "source_type": "query", "last_run_status": "succeeded"}]
```
`last_run_status` values: `succeeded`, `failed`, `running`, `null` (never run).
## get-transform-run
```bash
./metabase-agent get-transform-run <run_id>
```
Check status of a specific run. Terminal statuses: `succeeded`, `failed`, `timeout`, `canceled`.
## update-transform
```bash
./metabase-agent update-transform <id> --json '<payload>'
```
### Update with MBQL (preferred)
```json
{
"name": "Updated Name",
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
"database_id": 1
}
```
### Update with SQL (fallback)
```json
{
"name": "Updated Name",
"description": "New description",
"sql": "SELECT ...",
"database_id": 1,
"target_table": "new_table_name",
"target_schema": "new_schema"
}
```
All fields are optional. If updating `sql` or `query`, you MUST include `database_id`.
## Gotchas
- SQL strings with single quotes need shell escaping: `'"'"'` or `'\''`
- `target_table` creates a real table in the target database. Choose names carefully.
- After a transform runs, the output table appears in `list-databases --include tables`
- Transforms are independent of questions. A question can query the transform's output table using MBQL or SQL.
- Transform runs can take time. The `--run` flag polls automatically, but long-running transforms may hit the 5-minute polling timeout. Use `get-transform-run` to check afterward.
- **Always prefer MBQL transforms** — they benefit from type safety, field resolution, and cross-database compatibility. Use SQL only for queries MBQL can't express (CTEs, window functions, database-specific syntax).

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node --import tsx
import "../agent-toolkit/src/index.ts";

View File

@@ -0,0 +1,56 @@
# Common Patterns
## Setup
```bash
export METABASE_URL="http://localhost:3000"
export METABASE_API_KEY="mb_abc123..."
```
Or pass as flags: `--url`, `--api-key`, `--session-token`.
## Global Flags
| Flag | Effect |
|------|--------|
| `--fields id,name` | Only include these keys in response JSON |
| `--max-rows 200` | Override default 50-row cap on query results |
| `--dry-run` | Show API calls without executing |
| `--url <url>` | Metabase URL (overrides METABASE_URL) |
| `--api-key <key>` | API key (overrides METABASE_API_KEY) |
| `--session-token <token>` | Session token (overrides METABASE_SESSION_TOKEN) |
## JSON Payload Format
All mutation commands use `--json '<payload>'`.
Shell escaping for single quotes in SQL:
```bash
./metabase-agent create-question --json '{"sql": "SELECT * FROM t WHERE name = '"'"'test'"'"'"}'
```
## Error Handling
All errors are structured JSON on stderr:
```json
{
"error": "error_code",
"message": "Human-readable description",
"hint": "Suggested fix (when available)"
}
```
Common error codes: `missing_config`, `api_error`, `unknown_field`, `invalid_parameter`, `missing_parameter`, `unknown_filter`, `field_resolution`.
## Introspection
```bash
# List all commands with JSON schemas
./metabase-agent schema
# Get exact JSON Schema for any command
./metabase-agent schema create-dashboard
# Preview API calls without executing
./metabase-agent create-dashboard --dry-run --json '...'
```

View File

@@ -0,0 +1,35 @@
# Field Name Resolution
The CLI resolves human-readable field names to numeric Metabase field IDs automatically. You never need to look up field IDs manually.
## How It Works
1. You provide a field name string (e.g., `"created_at"` or `"Created At"`)
2. The CLI fetches table metadata via the API
3. It matches against both `name` (snake_case) and `display_name` (Title Case), case-insensitively
4. Table metadata is cached per session -- repeated lookups for the same table cost nothing
## Commands That Accept Field Names
- `create-segment` / `update-segment`: the `field` property in each filter
- `create-dashboard`: filter targets `field` property
- `add-card-to-dashboard`: filter_mappings `field` property
## Error Behavior
If a field name is not found, the error includes a closest-match suggestion and all available fields:
```json
{
"error": "unknown_field",
"message": "Field 'create_at' not found on table 5",
"hint": "Did you mean 'created_at'? Available fields: id, created_at, total, status",
"available_fields": ["id", "created_at", "total", "status"]
}
```
## Tips
- Run `get-table <id>` first to see all field names and types
- Prefer `name` (snake_case) over `display_name` -- it's more stable across locale changes
- Numeric string values like `"42"` pass through as numeric IDs without resolution

View File

@@ -0,0 +1,546 @@
# MBQL Query Construction
MBQL (Metabase Query Language) queries are constructed using Clojure code evaluated by the `construct-query` command. The code uses `metabase.lib` functions — the same library powering Metabase's visual query builder. Queries are correct by construction: if the code evaluates, the query is valid.
## Workflow
```bash
# 1. Discover schema (get table names and field names)
./metabase-agent get-table <table_id>
# 2. Construct and run MBQL query in one step
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
# OR: construct MBQL JSON, then use with other commands
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
# --> returns MBQL JSON to pass to create-question, execute-query, create-transform, or create-measure
```
## construct-query Command
```bash
# Return MBQL JSON
./metabase-agent construct-query --database-id <id> --clj '<code>'
# Construct AND execute in one step (returns query results)
./metabase-agent construct-query --database-id <id> --run --clj '<code>'
```
Without `--run`, returns legacy MBQL JSON that can be passed to:
- `create-question --json '{"query": <result>, ...}'`
- `execute-query --json '{"query": <result>, ...}'`
- `create-transform --json '{"query": <result>, ...}'`
- `create-measure --json '{"definition": <result>, ...}'`
With `--run`, executes the query and returns results directly (columns, rows, metadata).
## Helper Functions
### `table` — Look up table metadata
```clojure
(table "ORDERS") ; by name (case-insensitive)
(table 42) ; by numeric ID
```
Both string names and numeric IDs are accepted. Names are case-insensitive. On error, lists available tables with their IDs.
### `field` — Look up field metadata
```clojure
(field "ORDERS" "TOTAL") ; by table name + field name (case-insensitive)
(field "total") ; by field name only (searches all tables)
(field 123) ; by numeric ID
```
**Two-arity** `(field "TABLE" "FIELD")` is the explicit form — always unambiguous.
**Single-arity** `(field "name")` searches across all tables. If the field name exists in exactly one table, it resolves automatically. If ambiguous (exists in multiple tables), it throws an error listing which tables have that field.
**Numeric ID** `(field 123)` looks up by field ID directly.
### `query` — Create a new query
```clojure
(query (table "ORDERS")) ; pass table metadata from (table ...)
```
## Function Reference
All functions are available at the top level — no namespace prefix needed. The query is built using threading: `(-> (query ...) (filter ...) (aggregate ...) ...)`.
### Filtering (22 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `filter` | `(filter query clause)` | Add a filter clause to a query |
| `=` | `(= field value)` | Equal to |
| `!=` | `(!= field value)` | Not equal to |
| `<` | `(< field value)` | Less than |
| `<=` | `(<= field value)` | Less than or equal |
| `>` | `(> field value)` | Greater than |
| `>=` | `(>= field value)` | Greater than or equal |
| `between` | `(between field low high)` | Between two values (inclusive) |
| `contains` | `(contains field string)` | String contains |
| `does-not-contain` | `(does-not-contain field string)` | String does not contain |
| `starts-with` | `(starts-with field string)` | String starts with |
| `ends-with` | `(ends-with field string)` | String ends with |
| `is-null` | `(is-null field)` | Value is null |
| `not-null` | `(not-null field)` | Value is not null |
| `is-empty` | `(is-empty field)` | Empty string |
| `not-empty` | `(not-empty field)` | Not empty string |
| `in` | `(in field [v1 v2 ...])` | Value in set |
| `not-in` | `(not-in field [v1 v2 ...])` | Value not in set |
| `time-interval` | `(time-interval field n unit)` | Within time interval (e.g., last 30 days) |
| `and` | `(and clause1 clause2 ...)` | Logical AND of filter clauses |
| `or` | `(or clause1 clause2 ...)` | Logical OR of filter clauses |
| `not` | `(not clause)` | Negate a filter clause |
| `inside` | `(inside lat-field lon-field n s e w)` | Geographic bounding box filter |
#### Filtering Examples
```clojure
;; Simple equality filter
(-> (query (table "ORDERS"))
(filter (= (field "ORDERS" "STATUS") "completed")))
;; Compound filter with AND/OR
(-> (query (table "ORDERS"))
(filter (and (>= (field "ORDERS" "TOTAL") 100)
(or (= (field "ORDERS" "STATUS") "completed")
(= (field "ORDERS" "STATUS") "shipped")))))
;; Time interval: orders from the last 30 days
(-> (query (table "ORDERS"))
(filter (time-interval (field "ORDERS" "CREATED_AT") -30 :day)))
;; Null check
(-> (query (table "ORDERS"))
(filter (not-null (field "ORDERS" "DISCOUNT"))))
;; String matching
(-> (query (table "PRODUCTS"))
(filter (contains (field "PRODUCTS" "TITLE") "Widget")))
```
### Aggregation (17 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `aggregate` | `(aggregate query clause)` | Add an aggregation to a query |
| `count` | `(count)` | Count rows |
| `sum` | `(sum field)` | Sum of values |
| `avg` | `(avg field)` | Average |
| `min` | `(min field)` | Minimum |
| `max` | `(max field)` | Maximum |
| `distinct` | `(distinct field)` | Count distinct values |
| `median` | `(median field)` | Median value |
| `stddev` | `(stddev field)` | Standard deviation |
| `var` | `(var field)` | Variance |
| `percentile` | `(percentile field p)` | Percentile (p between 0 and 1) |
| `share` | `(share clause)` | Proportion of rows matching a filter |
| `count-where` | `(count-where clause)` | Count rows matching a filter |
| `sum-where` | `(sum-where field clause)` | Sum where filter matches |
| `distinct-where` | `(distinct-where field clause)` | Distinct count where filter matches |
| `cum-count` | `(cum-count)` | Cumulative count |
| `cum-sum` | `(cum-sum field)` | Cumulative sum |
#### Aggregation Result Column Names
When a query has aggregations, the result columns are auto-named by operator:
- First of each type: `count`, `sum`, `avg`, `min`, `max`, etc.
- Duplicates get a suffix: `sum_2`, `sum_3`, `count_2`, etc.
Use these names in `visualization.y_axis` when creating questions:
```json
{"visualization": {"y_axis": ["sum", "count"]}}
```
#### Aggregation Examples
```clojure
;; Simple count
(-> (query (table "ORDERS"))
(aggregate (count)))
;; Multiple aggregations (result columns: "sum", "count", "avg")
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(aggregate (count))
(aggregate (avg (field "ORDERS" "TOTAL"))))
;; Conditional aggregation: count of completed orders
(-> (query (table "ORDERS"))
(aggregate (count-where (= (field "ORDERS" "STATUS") "completed"))))
;; Percentage of completed orders
(-> (query (table "ORDERS"))
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
;; 95th percentile of order totals
(-> (query (table "ORDERS"))
(aggregate (percentile (field "ORDERS" "TOTAL") 0.95)))
```
### Breakout / Grouping (3 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `breakout` | `(breakout query field-or-expr)` | Group results by a column |
| `with-temporal-bucket` | `(with-temporal-bucket field bucket)` | Apply time bucketing to a column |
| `with-binning` | `(with-binning field strategy)` | Apply numeric binning |
**Temporal bucket values**: `:minute`, `:hour`, `:day`, `:week`, `:month`, `:quarter`, `:year`, `:minute-of-hour`, `:hour-of-day`, `:day-of-week`, `:day-of-month`, `:day-of-year`, `:week-of-year`, `:month-of-year`, `:quarter-of-year`
#### Breakout Examples
```clojure
;; Group by month
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
;; Group by multiple columns
(-> (query (table "ORDERS"))
(aggregate (count))
(breakout (field "ORDERS" "STATUS"))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :quarter)))
;; Group by day of week (for pattern analysis)
(-> (query (table "ORDERS"))
(aggregate (count))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :day-of-week)))
```
### Ordering and Limiting (4 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `order-by` | `(order-by query orderable)` | Sort ascending (default) |
| `order-by` | `(order-by query orderable :desc)` | Sort with explicit direction |
| `order-by` | `(order-by query :desc orderable)` | Direction-first syntax (also works) |
| `order-by` | `(order-by query (desc orderable))` | Using desc/asc wrappers |
| `asc` | `(asc orderable)` | Mark for ascending sort |
| `desc` | `(desc orderable)` | Mark for descending sort |
| `limit` | `(limit query n)` | Limit number of rows |
`order-by` accepts field metadata, aggregation clauses, and breakout columns. For aggregations (like `(sum ...)`), it automatically finds the matching orderable column.
#### Order/Limit Examples
```clojure
;; Top 10 orders by total, descending
(-> (query (table "ORDERS"))
(order-by (field "ORDERS" "TOTAL") :desc)
(limit 10))
;; Same thing using desc wrapper
(-> (query (table "ORDERS"))
(order-by (desc (field "ORDERS" "TOTAL")))
(limit 10))
;; Direction-first syntax (also valid)
(-> (query (table "ORDERS"))
(order-by :desc (field "ORDERS" "TOTAL"))
(limit 10))
;; Order by aggregation result — top categories by revenue
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "ORDERS" "STATUS"))
(order-by (desc (sum (field "ORDERS" "TOTAL")))))
;; Revenue by month, chronological order
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
(order-by (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month) :asc))
```
### Expressions (35+ functions)
Named expressions create computed columns. Wrap each in `(expression query "name" expr)`.
#### Arithmetic
| Function | Example |
|----------|---------|
| `+` | `(+ (field "T" "A") (field "T" "B"))` |
| `-` | `(- (field "T" "PRICE") (field "T" "COST"))` |
| `*` | `(* (field "T" "QTY") (field "T" "PRICE"))` |
| `/` | `(/ (field "T" "TOTAL") (field "T" "COUNT"))` |
#### Conditional
| Function | Signature | Description |
|----------|-----------|-------------|
| `case` | `(case [[cond1 val1] [cond2 val2] ...])` | Case expression with condition/value pairs |
| `coalesce` | `(coalesce field1 field2 ...)` | First non-null value |
#### String Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `concat` | `(concat str1 str2 ...)` | Concatenate strings |
| `substring` | `(substring field start length)` | Extract substring (1-indexed) |
| `replace` | `(replace field old new)` | Replace occurrences |
| `upper` | `(upper field)` | Uppercase |
| `lower` | `(lower field)` | Lowercase |
| `trim` | `(trim field)` | Trim whitespace |
| `ltrim` | `(ltrim field)` | Trim leading whitespace |
| `rtrim` | `(rtrim field)` | Trim trailing whitespace |
| `length` | `(length field)` | String length |
| `regex-match-first` | `(regex-match-first field pattern)` | First regex match |
#### Math Functions
| Function | Description |
|----------|-------------|
| `abs` | Absolute value |
| `ceil` | Ceiling |
| `floor` | Floor |
| `round` | Round |
| `power` | Power (`(power field exp)`) |
| `sqrt` | Square root |
| `log` | Natural logarithm |
| `exp` | e^x |
#### Temporal Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| `now` | `(now)` | Current timestamp |
| `relative-datetime` | `(relative-datetime n unit)` | Relative time (e.g., -7 :day) |
| `absolute-datetime` | `(absolute-datetime str unit)` | Parse absolute datetime |
| `datetime-add` | `(datetime-add field n unit)` | Add time to datetime |
| `datetime-subtract` | `(datetime-subtract field n unit)` | Subtract time from datetime |
| `get-year` | `(get-year field)` | Extract year |
| `get-quarter` | `(get-quarter field)` | Extract quarter (1-4) |
| `get-month` | `(get-month field)` | Extract month (1-12) |
| `get-week` | `(get-week field)` | Extract week of year |
| `get-day` | `(get-day field)` | Extract day of month |
| `get-day-of-week` | `(get-day-of-week field)` | Extract day of week (1-7) |
| `get-hour` | `(get-hour field)` | Extract hour (0-23) |
| `get-minute` | `(get-minute field)` | Extract minute (0-59) |
| `get-second` | `(get-second field)` | Extract second (0-59) |
| `convert-timezone` | `(convert-timezone field target source)` | Convert timezone |
| `relative-time-interval` | `(relative-time-interval field n unit offset-n offset-unit)` | Relative time interval filter |
#### Advanced
| Function | Signature | Description |
|----------|-----------|-------------|
| `expression` | `(expression query "name" expr)` | Define a named expression (computed column) |
| `offset` | `(offset expr n)` | Access value from N rows before/after |
| `segment` | `(segment id)` | Reference a saved segment by ID |
#### Expression Examples
```clojure
;; Computed column: profit margin
(-> (query (table "PRODUCTS"))
(expression "profit_margin"
(/ (- (field "PRODUCTS" "PRICE") (field "PRODUCTS" "COST"))
(field "PRODUCTS" "PRICE"))))
;; Case expression: order size category
(-> (query (table "ORDERS"))
(expression "size_category"
(case [[(< (field "ORDERS" "TOTAL") 50) "Small"]
[(< (field "ORDERS" "TOTAL") 200) "Medium"]]
{:default "Large"})))
;; Date math: days since order
(-> (query (table "ORDERS"))
(expression "days_since_order"
(datetime-subtract (now) (field "ORDERS" "CREATED_AT") :day)))
;; Previous month comparison using offset
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
(expression "prev_month_revenue" (offset (sum (field "ORDERS" "TOTAL")) -1)))
```
### Joins (9 functions)
| Function | Signature | Description |
|----------|-----------|-------------|
| `join` | `(join query join-clause)` | Add a join to a query |
| `join-clause` | `(join-clause table-meta)` | Create a join clause for a table |
| `with-join-conditions` | `(with-join-conditions clause [cond ...])` | Set join conditions |
| `with-join-strategy` | `(with-join-strategy clause strategy)` | Set join type |
| `with-join-fields` | `(with-join-fields clause :all/:none)` | Include joined fields |
| `with-join-alias` | `(with-join-alias clause alias)` | Set join alias |
| `suggested-join-conditions` | `(suggested-join-conditions query table)` | Auto-detect join conditions |
| `joinable-columns` | `(joinable-columns query join-clause)` | Get columns available for join conditions |
| `joins` | `(joins query)` | Get existing joins on a query |
**Join strategies**: `:left-join` (default), `:right-join`, `:inner-join`
#### Join Examples
```clojure
;; Join orders with products
(let [q (query (table "ORDERS"))
products (table "PRODUCTS")]
(-> q
(join (-> (join-clause products)
(with-join-conditions
[(= (field "ORDERS" "PRODUCT_ID")
(field "PRODUCTS" "ID"))])
(with-join-fields :all)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "PRODUCTS" "CATEGORY"))))
;; Inner join with alias
(let [q (query (table "ORDERS"))
users (table "PEOPLE")]
(-> q
(join (-> (join-clause users)
(with-join-conditions
[(= (field "ORDERS" "USER_ID")
(field "PEOPLE" "ID"))])
(with-join-strategy :inner-join)
(with-join-fields :all)))
(filter (= (field "PEOPLE" "STATE") "CA"))
(aggregate (count))))
```
### Column Introspection (6 functions)
These functions return available columns at a given query stage. Useful for dynamic resolution.
| Function | Description |
|----------|-------------|
| `visible-columns` | All visible columns in the current stage |
| `filterable-columns` | Columns that can be used in filters |
| `breakoutable-columns` | Columns that can be used for grouping |
| `aggregable-columns` | Columns that can be aggregated |
| `orderable-columns` | Columns that can be used for sorting |
| `expressionable-columns` | Columns available for use in expressions |
### Query Lifecycle
| Function | Description |
|----------|-------------|
| `->legacy-MBQL` | Convert a pMBQL query to legacy MBQL format (done automatically by construct-query) |
## Complete Examples
### Revenue by Month (Line Chart)
```clojure
(-> (query (table "ORDERS"))
(filter (= (field "ORDERS" "STATUS") "completed"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
```
### Top Categories by Revenue (Bar Chart)
```clojure
(let [q (query (table "ORDERS"))
products (table "PRODUCTS")]
(-> q
(join (-> (join-clause products)
(with-join-conditions
[(= (field "ORDERS" "PRODUCT_ID")
(field "PRODUCTS" "ID"))])
(with-join-fields :all)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "PRODUCTS" "CATEGORY"))
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
(limit 10)))
```
### Conversion Rate (Scalar)
```clojure
(-> (query (table "ORDERS"))
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
```
### Orders with Computed Columns
```clojure
(-> (query (table "ORDERS"))
(expression "discount_pct"
(/ (field "ORDERS" "DISCOUNT") (field "ORDERS" "SUBTOTAL")))
(expression "is_large_order"
(case [[(>= (field "ORDERS" "TOTAL") 200) "Yes"]]
{:default "No"}))
(filter (not-null (field "ORDERS" "DISCOUNT")))
(order-by (desc (field "ORDERS" "TOTAL")))
(limit 50))
```
### Using let for Readable Multi-Table Queries
```clojure
(let [orders (table "ORDERS")
products (table "PRODUCTS")
people (table "PEOPLE")
q (query orders)]
(-> q
(join (-> (join-clause products)
(with-join-conditions
[(= (field "ORDERS" "PRODUCT_ID")
(field "PRODUCTS" "ID"))])
(with-join-fields :all)))
(join (-> (join-clause people)
(with-join-conditions
[(= (field "ORDERS" "USER_ID")
(field "PEOPLE" "ID"))])
(with-join-fields :all)))
(filter (and (= (field "PEOPLE" "STATE") "CA")
(time-interval (field "ORDERS" "CREATED_AT") -90 :day)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(aggregate (count))
(breakout (field "PRODUCTS" "CATEGORY"))))
```
### Top 10 Customers by Lifetime Value
```clojure
(let [q (query (table "ORDERS"))
people (table "PEOPLE")]
(-> q
(join (-> (join-clause people)
(with-join-conditions
[(= (field "ORDERS" "USER_ID")
(field "PEOPLE" "ID"))])
(with-join-fields :all)))
(aggregate (sum (field "ORDERS" "TOTAL")))
(aggregate (count))
(breakout (field "PEOPLE" "NAME"))
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
(limit 10)))
```
## Gotchas
- **`field` with two args**: `(field "TABLE" "FIELD")` — always works, even with joins.
- **`field` with one arg**: `(field "FIELD")` — searches all tables. Throws if ambiguous (field exists in multiple tables). Use the two-arg form to disambiguate.
- **`table` and `field` accept IDs**: `(table 42)` and `(field 123)` work with numeric IDs from discovery APIs.
- **Table/field names are case-insensitive**: `(field "orders" "total")` and `(field "ORDERS" "TOTAL")` are equivalent.
- **Multiple `aggregate` calls chain**: Each `(aggregate ...)` adds one aggregation. Don't pass multiple aggregations in a single call.
- **Aggregation result column names**: `sum`, `count`, `avg`, etc. Duplicates get `_2`, `_3` suffixes. Use these names in `visualization.y_axis`.
- **Ordering by aggregations**: Use `(order-by query (desc (sum (field ...))))` — the aggregation clause is auto-matched to the query's orderable columns.
- **`--run` flag**: Use `construct-query --run` to construct and execute in one step, avoiding two-step shell piping.
- **Direction in `order-by`**: All three syntaxes work: `(order-by q field :desc)`, `(order-by q :desc field)`, `(order-by q (desc field))`.
## Clojure Syntax Quick Reference
The SCI evaluator supports standard Clojure syntax:
| Form | Example | Description |
|------|---------|-------------|
| `->` | `(-> x (f) (g))` | Thread-first macro: `(g (f x))` |
| `let` | `(let [x 1] x)` | Local bindings |
| `fn` | `(fn [x] (* x 2))` | Anonymous function |
| `if` | `(if test then else)` | Conditional |
| Keywords | `:month`, `:desc` | Used for options/enums |
| Vectors | `[1 2 3]` | Used for lists of values |
| Maps | `{:key "value"}` | Key-value pairs |
| Strings | `"text"` | Double-quoted strings |

View File

@@ -0,0 +1,61 @@
# Visualization Types and Settings
## Display Types
| Type | Use for | Key settings |
|------|---------|-------------|
| `table` | Detailed data (default) | none needed |
| `line` | Time series, trends | x_axis, y_axis |
| `bar` | Comparisons, distributions | x_axis, y_axis, stack |
| `area` | Cumulative, stacked trends | x_axis, y_axis, stack |
| `combo` | Mixed chart types | x_axis, y_axis |
| `scatter` | Correlations | x_axis, y_axis |
| `waterfall` | Sequential changes | x_axis, y_axis |
| `pie` | Part-of-whole | dimension, metric |
| `row` | Horizontal bar chart | x_axis, y_axis |
| `scalar` | Single KPI number | none |
| `smartscalar` | KPI with trend | none |
| `gauge` | Progress toward target | none |
| `progress` | Progress bar | none |
| `pivot` | Cross-tabulations | x_axis, y_axis |
| `funnel` | Conversion funnels | x_axis, y_axis |
| `map` | Geographic data | map_type, map_region |
| `boxplot` | Statistical distribution | x_axis, y_axis |
| `sankey` | Flow diagrams | x_axis, y_axis |
## Settings Object
All settings are optional. The CLI translates these to Metabase's internal format.
| CLI key | Metabase key | Type | Notes |
|---------|-------------|------|-------|
| `x_axis` | `graph.dimensions` | string[] | X axis columns |
| `y_axis` | `graph.metrics` | string[] | Y axis columns |
| `stack` | `stackable.stack_type` | `"stacked"` / `"normalized"` | bar/area |
| `show_values` | `graph.show_values` | boolean | data labels |
| `x_axis_label` | `graph.x_axis.title_text` | string | axis title |
| `y_axis_label` | `graph.y_axis.title_text` | string | axis title |
| `line_style` | `line.style` | `"solid"` / `"dashed"` / `"dotted"` | |
| `line_interpolation` | `line.interpolate` | `"linear"` / `"cardinal"` / `"monotone"` | |
| `dimension` | `pie.dimension` | string[] | pie categories |
| `metric` | `pie.metric` | string | pie value column |
| `show_legend` | `pie.show_legend` | boolean | |
| `map_type` | `map.type` | `"pin"` / `"region"` / `"grid"` | |
| `map_region` | `map.region` | string | |
## Column Name Matching
`x_axis` and `y_axis` values must match SQL column aliases exactly:
```sql
SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1
```
Correct: `"x_axis": ["month"], "y_axis": ["revenue"]`
Wrong: `"x_axis": ["created_at"], "y_axis": ["total"]`
## Chart-Specific Notes
- **pie**: Use `dimension` and `metric` instead of `x_axis`/`y_axis`
- **scalar/smartscalar**: SQL should return one row, one column. No viz settings needed.
- **gauge/progress**: SQL should return a single numeric value.
- **pivot**: `x_axis` columns become row headers, `y_axis` is the aggregated value.

View File

@@ -0,0 +1,180 @@
---
name: building-dashboards
description: Creates and manages Metabase dashboards with card placement, auto-layout, and filter wiring. Use when building dashboards, placing question cards on grids, or connecting dashboard filters to card fields.
---
# Building Dashboards
@./../_shared/field-resolution.md
## create-dashboard
Create a dashboard with cards and filters in one step.
```bash
./metabase-agent create-dashboard --json '<payload>'
```
Full JSON template:
```json
{
"name": "Sales Dashboard",
"description": "Q4 sales overview",
"collection_id": 7,
"cards": [
{"card_id": 42, "width": 12, "height": 6},
{"card_id": 43, "width": 6, "height": 4},
{"card_id": 44, "width": 6, "height": 4}
],
"filters": [
{
"name": "Date Range",
"type": "date/range",
"targets": [
{"card_id": 42, "field": "created_at"},
{"card_id": 43, "field": "order_date"}
]
}
]
}
```
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `name` | yes | | Dashboard name |
| `description` | no | | Dashboard description |
| `collection_id` | no | | Collection to save in |
| `cards` | no | | Cards to place (see layout below) |
| `cards[].card_id` | yes | | Question/card ID from `create-question` |
| `cards[].width` | no | 6 | Width in grid units (max 24) |
| `cards[].height` | no | 4 | Height in grid units |
| `cards[].row` | no | auto | Row position |
| `cards[].col` | no | auto | Column position |
| `filters` | no | | Dashboard-level filters |
| `filters[].name` | yes | | Label shown on dashboard |
| `filters[].type` | yes | | Filter widget type (see table below) |
| `filters[].targets` | yes | | Cards and fields this filter connects to |
Internally chains POST (create dashboard) + PUT (add cards and filters) in one command.
Output:
```json
{"id": 15, "name": "Sales Dashboard", "cards_added": 3, "filters_added": 1}
```
## Auto-Layout (24-Column Grid)
Cards are placed left-to-right, wrapping to the next row when a card exceeds column 24.
If `row` and `col` are both specified, those exact positions are used. Otherwise, auto-layout applies.
Common layout patterns:
```json
// Full width
{"card_id": 1, "width": 24, "height": 6}
// Two equal columns
{"card_id": 2, "width": 12, "height": 4},
{"card_id": 3, "width": 12, "height": 4}
// Three equal columns
{"card_id": 4, "width": 8, "height": 4},
{"card_id": 5, "width": 8, "height": 4},
{"card_id": 6, "width": 8, "height": 4}
// Four equal columns (KPI row)
{"card_id": 7, "width": 6, "height": 3},
{"card_id": 8, "width": 6, "height": 3},
{"card_id": 9, "width": 6, "height": 3},
{"card_id": 10, "width": 6, "height": 3}
// One wide + one narrow
{"card_id": 11, "width": 16, "height": 6},
{"card_id": 12, "width": 8, "height": 6}
```
## Dashboard Filter Types
| type | Widget | Use for |
|------|--------|---------|
| `date/range` | Date range picker | Date columns |
| `date/single` | Single date picker | Exact date |
| `date/month-year` | Month picker | Monthly filtering |
| `date/quarter-year` | Quarter picker | Quarterly filtering |
| `date/relative` | Relative date | Dynamic ranges (last 30 days) |
| `category` | Dropdown | String/enum columns |
| `id` | ID input | Numeric ID columns |
| `number` | Number input | Numeric columns |
| `string` | Text input | Free text |
| `string/contains` | Text (contains) | Partial match |
| `string/starts-with` | Text (starts-with) | Prefix match |
Filter targets use field names that are resolved automatically (see field-resolution.md).
## add-card-to-dashboard
Add a card to an existing dashboard with optional filter wiring.
```bash
./metabase-agent add-card-to-dashboard --json '<payload>'
```
```json
{
"dashboard_id": 15,
"card_id": 50,
"width": 6,
"height": 4,
"filter_mappings": [
{"filter_name": "Date Range", "field": "created_at"}
]
}
```
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `dashboard_id` | yes | | Dashboard ID |
| `card_id` | yes | | Card/question ID to add |
| `width` | no | 6 | Width in grid units |
| `height` | no | 4 | Height in grid units |
| `filter_mappings` | no | | Wire to existing dashboard filters |
| `filter_mappings[].filter_name` | yes | | Must match existing filter name (case-insensitive) |
| `filter_mappings[].field` | yes | | Field name on this card to wire to |
New card is placed at the bottom of the dashboard below all existing cards.
Output:
```json
{"dashboard_id": 15, "card_id": 50, "position": {"row": 8, "col": 0}}
```
## get-dashboard
```bash
./metabase-agent get-dashboard <id>
```
Output:
```json
{
"id": 15,
"name": "Sales Dashboard",
"description": "Q4 overview",
"parameters": [{"id": "abc12345", "name": "Date Range", "type": "date/range"}],
"cards": [
{"dashcard_id": 100, "card_id": 42, "card_name": "Revenue by Month",
"size_x": 12, "size_y": 6, "row": 0, "col": 0}
]
}
```
Use this to see current layout before adding cards or to verify dashboard creation.
## Gotchas
- `card_id` is the `id` from `create-question`, NOT the `dashcard_id` shown in `get-dashboard`
- For SQL cards, filter wiring requires the card to have been run at least once (so `result_metadata` exists). Run the question in Metabase UI or use `execute-query` first.
- `add-card-to-dashboard` reads existing cards and PUTs the entire array back. It handles this correctly -- you just provide the new card.
- Filter `targets` use field names, not IDs. The field must exist on the card's source table or SQL result columns.
- Card `width` must be between 1 and 24. Common widths: 6 (quarter), 8 (third), 12 (half), 16 (two-thirds), 24 (full).
- Mixing auto-layout (no row/col) with explicit positions: auto-layout cards fill in array order. For predictable results, use all-auto or all-explicit.

View File

@@ -0,0 +1,435 @@
---
name: creating-questions
description: Creates saved questions with visualizations, reusable filter segments, measures, metrics, and runs ad-hoc MBQL or SQL queries in Metabase. Use when saving queries as named questions with charts, defining reusable filters/aggregations, or running exploratory queries.
---
# Creating Questions, Segments, Measures, and Queries
@./../_shared/visualization-settings.md
@./../_shared/field-resolution.md
@./../_shared/mbql-construction.md
## construct-query
Construct an MBQL query by evaluating Clojure code with `metabase.lib` functions. Returns legacy MBQL JSON or (with `--run`) executes the query directly.
```bash
# Return MBQL JSON
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
# Construct AND execute in one step
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
```
Example:
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(filter (= (field "ORDERS" "STATUS") "completed"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
```
Returns a JSON object like:
```json
{"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ...]], ...}}
```
With `--run`, returns query results (columns, rows, metadata) instead of MBQL JSON.
See the MBQL Construction reference for all available functions and examples.
## create-question
Create a saved question (card) with visualization settings. **Always prefer MBQL** — Metabase can do much more with structured queries (drill-down, auto-filters, column-level metadata, cross-database compatibility). Use SQL only when MBQL can't express the query.
```bash
./metabase-agent create-question --json '<payload>'
```
### MBQL question (structured query — preferred)
First construct the MBQL query with `construct-query`, then use the output as the `query` field:
```json
{
"name": "Revenue by Month",
"database_id": 1,
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]], "breakout": [["field", 20, {"temporal-unit": "month"}]]},
"display": "line",
"visualization": {
"x_axis": ["CREATED_AT"],
"y_axis": ["sum"]
}
}
```
### SQL question (native query — use only when MBQL can't express the query)
```json
{
"name": "Revenue by Month",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
"display": "line",
"type": "question",
"visualization": {
"x_axis": ["month"],
"y_axis": ["revenue"],
"y_axis_label": "Revenue ($)",
"show_values": true
},
"collection_id": 7,
"description": "Monthly revenue trend"
}
```
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `name` | yes | | Question name |
| `database_id` | yes | | Database ID |
| `sql` | one of sql/query | | SQL query (native question) |
| `query` | one of sql/query | | MBQL query object from `construct-query` |
| `display` | no | `"table"` | Chart type (see visualization-settings.md) |
| `type` | no | `"question"` | `"question"`, `"model"`, or `"metric"` |
| `visualization` | no | | Chart settings (see visualization-settings.md) |
| `collection_id` | no | | Save to specific collection |
| `description` | no | | Question description |
Exactly one of `sql` or `query` must be provided.
Output:
```json
{"id": 42, "name": "Revenue by Month", "display": "line", "type": "question"}
```
The returned `id` is the `card_id` you pass to dashboard commands.
### Example: Line chart (MBQL — recommended)
Two-step workflow: first construct the MBQL, then create the question.
```bash
# Step 1: Construct MBQL query
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# --> returns {"database": 1, "type": "query", "query": {...}}
# Step 2: Create question with the MBQL output as the "query" field
./metabase-agent create-question --json '{
"name": "Revenue Trend",
"database_id": 1,
"query": <paste output from step 1>,
"display": "line",
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
}'
```
For MBQL questions, `visualization.y_axis` uses the aggregation operator name (`sum`, `count`, `avg`, etc.), not SQL aliases.
### Example: Top 10 bar chart (MBQL with ordering)
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (field "ORDERS" "STATUS"))
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
(limit 10))'
```
### Example: Pie chart (MBQL)
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (count))
(breakout (field "ORDERS" "STATUS")))'
./metabase-agent create-question --json '{
"name": "Orders by Status",
"database_id": 1,
"query": <paste output>,
"display": "pie",
"visualization": {"dimension": ["STATUS"], "metric": "count"}
}'
```
### Example: KPI scalar (MBQL)
```bash
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL"))))'
./metabase-agent create-question --json '{
"name": "Total Revenue",
"database_id": 1,
"query": <paste output>,
"display": "scalar"
}'
```
### Example: Metric (reusable aggregation on a card)
Metrics are cards with `type: "metric"`. They define a reusable aggregation:
```bash
./metabase-agent create-question --json '{
"name": "Total Revenue",
"database_id": 1,
"type": "metric",
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]},
"display": "scalar",
"description": "Sum of all order totals"
}'
```
### Example: SQL fallback (use only when MBQL can't express the query)
```bash
./metabase-agent create-question --json '{
"name": "Revenue Trend",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
"display": "line",
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
}'
```
## update-question
```bash
./metabase-agent update-question <id> --json '<payload>'
```
All fields are optional. If updating `sql`, you MUST include `database_id`.
```json
{
"name": "Updated Name",
"display": "bar",
"sql": "SELECT ...",
"database_id": 1,
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"], "stack": "stacked"},
"description": "Updated description"
}
```
## create-segment
Create a reusable filter (segment) on a table using field names.
```bash
./metabase-agent create-segment --json '<payload>'
```
Full JSON template:
```json
{
"name": "Active Users",
"table_id": 5,
"filters": [
{"field": "last_login", "op": "greater-than", "value": "2024-01-01"},
{"field": "status", "op": "equals", "value": "active"}
],
"description": "Users active in 2024"
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Segment name |
| `table_id` | yes | Table ID |
| `filters` | yes (min 1) | Filter conditions using field names |
| `description` | no | Segment description |
Multiple filters are ANDed together automatically. Field names are resolved to IDs via the table's metadata (see field-resolution.md).
### Filter Operations
| Operation | Description | Needs value? |
|-----------|-------------|-------------|
| `equals` | Exact match | yes |
| `not-equals` | Not equal | yes |
| `greater-than` | > | yes |
| `greater-than-or-equal` | >= | yes |
| `less-than` | < | yes |
| `less-than-or-equal` | <= | yes |
| `contains` | String contains | yes |
| `not-contains` | Does not contain | yes |
| `starts-with` | String starts with | yes |
| `ends-with` | String ends with | yes |
| `is-null` | Value is null | no |
| `is-not-null` | Value is not null | no |
| `is-empty` | Empty string | no |
| `is-not-empty` | Not empty string | no |
Use `values` (array) instead of `value` for multi-value matching.
Output:
```json
{"id": 10, "name": "Active Users", "table_id": 5}
```
## update-segment
```bash
./metabase-agent update-segment <id> --json '<payload>'
```
```json
{
"name": "Updated Name",
"description": "New description",
"table_id": 5,
"filters": [{"field": "status", "op": "equals", "value": "active"}],
"revision_message": "Updated filter criteria"
}
```
If updating `filters`, you MUST include `table_id`. `revision_message` defaults to "Updated via CLI".
## create-measure
Create a reusable aggregation expression (measure) tied to a table. Measures use MBQL definitions with exactly one aggregation.
```bash
./metabase-agent create-measure --json '<payload>'
```
```json
{
"name": "Total Revenue",
"table_id": 5,
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]}},
"description": "Sum of all order totals"
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Measure name |
| `table_id` | yes | Table ID this measure belongs to |
| `definition` | yes | MBQL5 query object (from `construct-query`) |
| `description` | no | Measure description |
Workflow: Use `construct-query` to build the MBQL, then pass the output as `definition`.
Output:
```json
{"id": 3, "name": "Total Revenue", "table_id": 5}
```
## list-measures
```bash
./metabase-agent list-measures
```
Output:
```json
[{"id": 3, "name": "Total Revenue", "table_id": 5, "description": "Sum of all order totals"}]
```
## get-measure
```bash
./metabase-agent get-measure <id>
```
## update-measure
```bash
./metabase-agent update-measure <id> --json '<payload>'
```
```json
{
"name": "Updated Name",
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["avg", ["field", 23, null]]]}},
"revision_message": "Changed from sum to avg"
}
```
`revision_message` defaults to "Updated via CLI".
## execute-query
Run ad-hoc queries without saving. Prefer `construct-query --run` for MBQL — it's simpler. Use `execute-query` when you already have a raw query object or need SQL.
```bash
./metabase-agent execute-query --json '<payload>'
```
### MBQL execution (preferred — or use `construct-query --run` instead)
```json
{
"database_id": 1,
"query": {"source-table": 5, "aggregation": [["count"]], "filter": ["=", ["field", 50, null], "completed"]}
}
```
### SQL execution (use only when MBQL can't express the query)
```json
{
"database_id": 1,
"sql": "SELECT COUNT(*) AS total_orders FROM orders WHERE status = 'completed'"
}
```
Exactly one of `sql` or `query` must be provided.
Output:
```json
{
"status": "completed",
"columns": [{"name": "total_orders", "display_name": "Total Orders", "base_type": "type/Integer"}],
"rows": [[1523]],
"row_count": 1,
"running_time": 245,
"_meta": {"truncated": false, "total_count": 1, "returned_count": 1}
}
```
Default max 50 rows. Override with `--max-rows 200`. Results are ephemeral -- use `create-question` to save.
## run-question
Run a saved question and return sample results.
```bash
./metabase-agent run-question <id>
./metabase-agent run-question <id> --max-rows 20
```
Default: 10 rows. Use this to verify a question works before adding it to a dashboard, or to inspect the data shape.
Output:
```json
{"card_id": 42, "status": "completed",
"columns": [{"name": "month", "display_name": "Month", "base_type": "type/DateTime"},
{"name": "revenue", "display_name": "Revenue", "base_type": "type/Float"}],
"rows": [["2024-01-01T00:00:00Z", 12345.67]],
"row_count": 12, "running_time": 150,
"_meta": {"truncated": true, "total_count": 12, "returned_count": 10}}
```
## Gotchas
- **Always use MBQL** unless the query requires SQL-specific features (CTEs, window functions, database-specific syntax, etc.). MBQL questions support drill-down, auto-filters, and cross-database compatibility that SQL questions cannot provide.
- `create-question` returns `id` -- this is the `card_id` for dashboard commands
- **MBQL y_axis**: Use aggregation operator names: `"y_axis": ["sum"]`, `"y_axis": ["sum", "count"]`. Duplicates get suffixes: `sum_2`, `sum_3`
- **SQL y_axis**: Column aliases must match `x_axis`/`y_axis` exactly: `SELECT SUM(total) AS revenue` means `"y_axis": ["revenue"]`
- For pie charts, use `dimension` and `metric`, not `x_axis`/`y_axis`
- `type: "model"` creates a Metabase model (queryable like a table)
- `type: "metric"` creates a reusable metric aggregation
- Segment field names are resolved against table metadata. Run `get-table <table_id>` first to confirm field names.
- `execute-query` does NOT save anything. Use `create-question` for persistent results.
- MBQL queries from `construct-query` include the full `dataset_query` structure -- pass the entire output as the `query` field.
- Measures require MBQL definitions -- use `construct-query` to build them.
- Use `construct-query --run` to test MBQL queries ad-hoc without creating a saved question.

View File

@@ -0,0 +1,170 @@
---
name: discovering-data
description: Discovers databases, tables, fields, and existing content in Metabase. Use when exploring data sources, understanding schema, or searching for existing dashboards and questions before building new analytics.
---
# Discovering Data
Always start here before creating transforms, questions, or dashboards.
@./../_shared/common-patterns.md
## list-databases
List all databases, optionally including their tables.
```bash
# Compact list
./metabase-agent list-databases --fields id,name,engine
# Include table names (recommended first step)
./metabase-agent list-databases --include tables
```
Output shape:
```json
[{"id": 1, "name": "Production DB", "engine": "postgres",
"tables": [{"id": 5, "name": "orders", "schema": "public"}]}]
```
This gives you `database_id` and `table_id` values needed by all other commands.
## get-database
Full database metadata with every table and field.
```bash
./metabase-agent get-database 1
```
Output shape:
```json
{"id": 1, "name": "Production DB", "engine": "postgres",
"tables": [{"id": 5, "name": "orders", "schema": "public", "display_name": "Orders",
"fields": [{"id": 42, "name": "id", "display_name": "ID",
"base_type": "type/Integer", "semantic_type": "type/PK"}]}]}
```
Use when you need field-level detail for all tables in one call. Output can be large for big databases -- prefer `get-table` for specific tables.
## get-table
Single table with all fields, types, and foreign keys.
```bash
./metabase-agent get-table 5
```
Output shape:
```json
{"id": 5, "name": "orders", "display_name": "Orders", "schema": "public",
"db_id": 1, "description": "...",
"fields": [{"id": 42, "name": "created_at", "display_name": "Created At",
"base_type": "type/DateTime", "semantic_type": null,
"fk_target_field_id": null}]}
```
Key field properties:
- `base_type`: `type/Integer`, `type/Text`, `type/DateTime`, `type/Float`, `type/Boolean`
- `semantic_type`: `type/FK` (foreign key), `type/PK` (primary key), `type/Email`, etc.
- `fk_target_field_id`: non-null for foreign keys, points to the target field
This command tells you what field names to use in segments, questions, and dashboard filter targets.
## search
Free-text search across all Metabase content.
```bash
./metabase-agent search "revenue" --models table,card,dashboard
```
`--models` values: `table`, `dashboard`, `card`, `metric`, `segment`
Output shape:
```json
{"results": [{"id": 5, "name": "Revenue Report", "model": "card",
"description": "...", "database_id": 1, "table_id": 5}],
"_meta": {"total_count": 3}}
```
Use to find existing content before creating duplicates.
## get-field
Get full field metadata including statistical fingerprint.
```bash
./metabase-agent get-field 42
```
Output shape:
```json
{"id": 42, "name": "total", "display_name": "Total", "table_id": 5,
"base_type": "type/Float", "semantic_type": null,
"has_field_values": "none",
"fingerprint": {
"distinct_count": 200, "null_percent": 0.0,
"min": 12.5, "max": 1250.0, "avg": 89.43, "q1": 35.0, "q3": 120.0, "sd": 75.2
}}
```
Fingerprint content depends on field type:
- **Numbers**: `min`, `max`, `avg`, `q1`, `q3`, `sd` (standard deviation), `distinct_count`, `null_percent`
- **Dates**: `earliest`, `latest`, `distinct_count`, `null_percent`
- **Text**: `average_length`, `distinct_count`, `null_percent`
Use to understand value ranges before building filters (e.g., knowing the min/max of a price field).
## get-field-values
Get distinct values stored in a field. Essential for building correct filters.
```bash
./metabase-agent get-field-values 50
```
Output shape:
```json
{"field_id": 50, "values": ["cancelled", "completed", "pending", "shipped"], "has_more_values": false}
```
Use this to discover valid filter values. For example, before writing `(filter (= (field "ORDERS" "STATUS") "completed"))`, run `get-field-values` on the status field to see all possible values.
Notes:
- Only works for fields with `has_field_values: "list"` (typically low-cardinality fields like status, category, etc.)
- For high-cardinality fields (IDs, timestamps), returns empty values — use `get-field` fingerprint instead for range info
- If a field has remapped display values, returns `[original_value, display_name]` pairs
## ping
Health check. No arguments.
```bash
./metabase-agent ping
```
## Discovery Workflow
```
1. ./metabase-agent list-databases --include tables --fields id,name
--> Pick database_id, note table names
2. ./metabase-agent get-table <table_id>
--> Learn field names, types, relationships
3. ./metabase-agent get-field-values <field_id>
--> Discover valid values for categorical fields (status, type, category)
4. ./metabase-agent get-field <field_id>
--> Check value ranges for numeric/date fields (min, max, avg, date range)
5. ./metabase-agent search "keyword" --models card,dashboard
--> Check if the analysis already exists
```
## Gotchas
- `get-database` returns ALL tables. For large databases (100+ tables), use `list-databases --include tables` then `get-table` for specific tables.
- Search results are capped server-side. Use `--models` to narrow results.
- Table IDs, database IDs, and field IDs are stable. You can reference them across commands.

View File

@@ -0,0 +1,164 @@
---
name: metabase-agent-workflow
description: Orchestrates the Metabase agent CLI workflow for building analytics from raw data. Use when the goal involves discovering databases, creating transforms, defining questions or segments, or building dashboards.
---
# Metabase Agent Workflow
Build analytics from raw data in four phases:
```
1. Discover --> 2. Model --> 3. Define --> 4. Visualize
```
| Phase | What it does | What it produces |
|-------|-------------|-----------------|
| Discover | Explore databases, tables, fields | database_id, table_id, field names |
| Model | Clean/join/aggregate data with MBQL or SQL transforms | New tables in the database |
| Define | Save MBQL (preferred) or SQL as questions with charts; create segments, measures, metrics | card_id values for dashboards |
| Visualize | Build dashboards with cards and filters | Stakeholder-ready dashboards |
## Command Reference
| Goal | Command | Phase |
|------|---------|-------|
| Find databases and tables | `list-databases --include tables` | Discover |
| Inspect table fields and types | `get-table <id>` | Discover |
| Search existing content | `search "query" --models table,card` | Discover |
| Full database schema | `get-database <id>` | Discover |
| Field metadata + fingerprint | `get-field <id>` | Discover |
| Distinct values for a field | `get-field-values <id>` | Discover |
| Create MBQL/SQL transform | `create-transform --json '{...}'` | Model |
| Inspect transform | `get-transform <id>` | Model |
| List transforms with status | `list-transforms` | Model |
| Check transform run | `get-transform-run <run_id>` | Model |
| Construct MBQL query | `construct-query --database-id <id> --clj '<code>'` | Define |
| Construct + run MBQL | `construct-query --database-id <id> --run --clj '<code>'` | Define |
| Save MBQL/SQL as a chart | `create-question --json '{...}'` | Define |
| Create reusable filter | `create-segment --json '{...}'` | Define |
| Create reusable aggregation | `create-measure --json '{...}'` | Define |
| List all measures | `list-measures` | Define |
| Get measure details | `get-measure <id>` | Define |
| Update a measure | `update-measure <id> --json '{...}'` | Define |
| Run ad-hoc MBQL or SQL | `execute-query --json '{...}'` | Define |
| Run saved question | `run-question <id>` | Define |
| Build dashboard with cards | `create-dashboard --json '{...}'` | Visualize |
| Add card to dashboard | `add-card-to-dashboard --json '{...}'` | Visualize |
| View dashboard layout | `get-dashboard <id>` | Visualize |
## Essential Rules
- **Always use MBQL** for queries. MBQL questions support drill-down, auto-filters, column-level metadata, and cross-database compatibility that SQL questions cannot provide. Use SQL only when MBQL genuinely can't express the query (e.g., CTEs, window functions, database-specific syntax).
- All mutations use `--json '<payload>'` with simplified JSON (not raw API format)
- Field names (like `"created_at"`) are resolved to numeric IDs automatically -- never look up IDs manually
- Queries use MBQL constructed via `construct-query` (preferred) or native SQL (fallback)
- `construct-query --run` lets you construct and execute MBQL in one step (no need to pipe through `execute-query`)
- In MBQL, `table` and `field` accept both string names and numeric IDs: `(table "ORDERS")` or `(table 42)`
- `field` with one argument searches all tables: `(field "total")` — use two args if ambiguous
- Use `--dry-run` before mutations to preview API calls
- Use `--fields id,name` to keep responses compact
- Use `./metabase-agent schema <command>` to see exact JSON Schema for any command
## End-to-End Example (MBQL — recommended)
```bash
# 1. Discover: find the database and inspect tables
./metabase-agent list-databases --include tables --fields id,name
./metabase-agent get-table 5
# 2. Explore: test an MBQL query ad-hoc (construct + execute in one step)
./metabase-agent construct-query --database-id 1 --run --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# --> returns query results (columns, rows)
# 3. Define: construct MBQL (without --run) to get the query JSON
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# --> returns {"database": 1, "type": "query", "query": {...}}
# 4. Define: create question with MBQL result
./metabase-agent create-question --json '{
"name": "Revenue by Month",
"database_id": 1,
"query": <output from construct-query>,
"display": "line",
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
}'
# 5. Define: create a reusable measure
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL"))))'
./metabase-agent create-measure --json '{
"name": "Total Revenue",
"table_id": 5,
"definition": <output from construct-query>
}'
# 6. Visualize: build a dashboard
./metabase-agent create-dashboard --json '{
"name": "Sales Dashboard",
"cards": [{"card_id": 42, "width": 24, "height": 6}]
}'
```
## End-to-End Example (SQL — use only when MBQL can't express the query)
```bash
# 1. Discover: find the database and tables
./metabase-agent list-databases --include tables --fields id,name
# 2. Discover: inspect the orders table
./metabase-agent get-table 5
# 3. Define: create a chart question
./metabase-agent create-question --json '{
"name": "Revenue by Month",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
"display": "line",
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
}'
# --> returns {"id": 42, ...}
# 3b. Verify: run the question and check sample results
./metabase-agent run-question 42
# 4. Visualize: build a dashboard
./metabase-agent create-dashboard --json '{
"name": "Sales Dashboard",
"cards": [{"card_id": 42, "width": 24, "height": 6}],
"filters": [{"name": "Date", "type": "date/range",
"targets": [{"card_id": 42, "field": "month"}]}]
}'
```
## Checklist
```
- [ ] Discover: identify database_id and table_ids
- [ ] Discover: inspect field names and types with get-table
- [ ] Discover: check field values (get-field-values) for filter fields
- [ ] (Optional) Model: create-transform if raw data needs cleaning
- [ ] Define: construct-query for MBQL (preferred) — use SQL only if MBQL can't express it
- [ ] Define: create-question for each visualization needed
- [ ] Define: (optional) create-segment for reusable filters
- [ ] Define: (optional) create-measure for reusable aggregations
- [ ] Define: (optional) create metric with type: "metric"
- [ ] Visualize: create-dashboard with card_ids from above
- [ ] Visualize: wire dashboard filters to card fields
```
## Detailed Skill References
For complete JSON formats, examples, and gotchas for each command group:
- [Discovering Data](./../discovering-data/SKILL.md) -- databases, tables, search
- [Modeling Transforms](./../modeling-transforms/SKILL.md) -- MBQL/SQL transforms
- [Creating Questions](./../creating-questions/SKILL.md) -- questions, segments, measures, queries, MBQL construction
- [Building Dashboards](./../building-dashboards/SKILL.md) -- dashboards, cards, filters
- [Common Patterns](./../_shared/common-patterns.md) -- setup, global flags, errors

View File

@@ -0,0 +1,195 @@
---
name: modeling-transforms
description: Creates and manages MBQL or SQL transforms that materialize query results into database tables. Use when raw data needs cleaning, joining, or restructuring before building questions and dashboards.
---
# Modeling Transforms
Transforms run MBQL (preferred) or SQL queries and write the results to a new database table. Use them when raw tables need cleaning, joining, or aggregation before downstream analysis. Use SQL only when MBQL can't express the query.
Do NOT use transforms for: one-off queries (use `execute-query`) or saved questions that just need a chart (use `create-question`).
@./../_shared/mbql-construction.md
## create-transform
```bash
./metabase-agent create-transform --json '<payload>'
```
### MBQL transform (preferred)
First use `construct-query` to build the MBQL query, then pass the output as the `query` field:
```json
{
"name": "Clean Orders",
"database_id": 1,
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
"target_table": "clean_orders",
"target_schema": "transforms",
"description": "Orders excluding cancelled",
"run": true
}
```
### SQL transform (use only when MBQL can't express the query)
```json
{
"name": "Clean Orders",
"database_id": 1,
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
"target_table": "clean_orders",
"target_schema": "transforms",
"target_database_id": 2,
"description": "Orders excluding cancelled",
"run": true
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Transform name |
| `database_id` | yes | Source database ID |
| `sql` | one of sql/query | SQL query for the transform |
| `query` | one of sql/query | MBQL query object from `construct-query` |
| `target_table` | yes | Output table name |
| `target_schema` | no | Schema for output table |
| `target_database_id` | no | Defaults to `database_id` |
| `description` | no | Description |
| `run` | no | Execute immediately after creation |
Exactly one of `sql` or `query` must be provided.
### Example: MBQL transform (recommended)
```bash
# 1. Construct the MBQL query
./metabase-agent construct-query --database-id 1 --clj '
(-> (query (table "ORDERS"))
(aggregate (sum (field "ORDERS" "TOTAL")))
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
# 2. Use the output as the query field
./metabase-agent create-transform --json '{
"name": "Monthly Revenue",
"database_id": 1,
"query": <output from construct-query>,
"target_table": "monthly_revenue",
"target_schema": "transforms",
"run": true
}'
```
### Example: SQL transform (fallback)
```bash
./metabase-agent create-transform --json '{
"name": "Monthly Revenue",
"database_id": 1,
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1",
"target_table": "monthly_revenue",
"target_schema": "transforms",
"run": true
}'
```
Output when `run: true`:
```json
{"transform": {"id": 15, "name": "Monthly Revenue"},
"run": {"status": "succeeded", "run_id": 123}}
```
When `run: true`, the CLI polls until completion (up to 5 minutes) or returns `polling_timeout`.
Output when `run: false` or omitted:
```json
{"id": 15, "name": "Monthly Revenue", "source_type": "query"}
```
## get-transform
Get a transform's full definition including its query, target table, and last run status.
```bash
./metabase-agent get-transform <id>
```
Output:
```json
{
"id": 15,
"name": "Clean Orders",
"description": "Orders excluding cancelled",
"database_id": 1,
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
"target_table": "clean_orders",
"target_schema": "transforms",
"last_run_status": "succeeded",
"last_run_at": "2024-12-01T10:30:00Z"
}
```
Use this to inspect an existing transform's query before updating it.
## list-transforms
```bash
./metabase-agent list-transforms
```
Output:
```json
[{"id": 15, "name": "Clean Orders", "source_type": "query", "last_run_status": "succeeded"}]
```
`last_run_status` values: `succeeded`, `failed`, `running`, `null` (never run).
## get-transform-run
```bash
./metabase-agent get-transform-run <run_id>
```
Check status of a specific run. Terminal statuses: `succeeded`, `failed`, `timeout`, `canceled`.
## update-transform
```bash
./metabase-agent update-transform <id> --json '<payload>'
```
### Update with MBQL (preferred)
```json
{
"name": "Updated Name",
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
"database_id": 1
}
```
### Update with SQL (fallback)
```json
{
"name": "Updated Name",
"description": "New description",
"sql": "SELECT ...",
"database_id": 1,
"target_table": "new_table_name",
"target_schema": "new_schema"
}
```
All fields are optional. If updating `sql` or `query`, you MUST include `database_id`.
## Gotchas
- SQL strings with single quotes need shell escaping: `'"'"'` or `'\''`
- `target_table` creates a real table in the target database. Choose names carefully.
- After a transform runs, the output table appears in `list-databases --include tables`
- Transforms are independent of questions. A question can query the transform's output table using MBQL or SQL.
- Transform runs can take time. The `--run` flag polls automatically, but long-running transforms may hit the 5-minute polling timeout. Use `get-transform-run` to check afterward.
- **Always prefer MBQL transforms** — they benefit from type safety, field resolution, and cross-database compatibility. Use SQL only for queries MBQL can't express (CTEs, window functions, database-specific syntax).

4
agent-toolkit/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
lib/cljs/
*.tsbuildinfo

View File

@@ -0,0 +1,79 @@
# Agent Toolkit CLI — Change Requests
## Round 2: Shopify Dashboard (MBQL-based)
### Context
An AI agent built a 10-card Shopify overview dashboard using MBQL queries via `construct-query`. Many issues from Round 1 have been fixed (single-arity `field`, `asc`/`desc` helpers, `--run` flag, skill docs, MBQL examples in `create-question`). Two new issues were encountered.
### Issue 1: `field` two-arg form doesn't accept numeric table IDs
**What happened**: After discovering tables with `list-databases --include tables` (which returns `{"id": 142, "name": "int_shopify_order_facts", ...}`), I tried using the table ID in `field`:
```clojure
(field 142 "is_cancelled")
;; Error: "Field not found: 142.is_cancelled"
```
Had to switch to string table names:
```clojure
(field "int_shopify_order_facts" "is_cancelled") ;; works
```
**Why this matters**: The discovery phase returns numeric IDs. The `table` helper accepts both `(table 142)` and `(table "int_shopify_order_facts")`, but `field` only accepts `(field "table_name" "field_name")` in two-arg form. This is inconsistent — if `table` accepts IDs, `field` should too.
**Suggested fix**: Support `(field <table-id> "field_name")` when the first argument is a number:
```clojure
(field 142 "is_cancelled") ;; resolve table by ID 142, then find "is_cancelled" in it
```
### Issue 2: `order-by` fails with multiple same-operator aggregations on different fields
**What happened**: A query with two `sum` aggregations on different fields fails when trying to sort by one of them:
```clojure
(-> (query (table "int_shopify_order_line_facts"))
(aggregate (sum (field "int_shopify_order_line_facts" "line_net_amount")))
(aggregate (sum (field "int_shopify_order_line_facts" "quantity")))
(breakout (field "int_shopify_order_line_facts" "product_title"))
(order-by (desc (sum (field "int_shopify_order_line_facts" "line_net_amount"))))
(limit 10))
;; Error: "Ambiguous: multiple 'sum' aggregations found.
;; Use orderable-columns to pick the specific column."
```
**Why this matters**: The `order-by` expression includes the full field reference `(sum (field ... "line_net_amount"))`, which should be enough to identify which `sum` to sort by. The two aggregations operate on different fields (`line_net_amount` vs `quantity`), so they're distinguishable.
The workaround was to drop the second aggregation entirely, losing useful data from the visualization. I wanted "Top 10 Products" showing both revenue and units sold, sorted by revenue — but had to show revenue only.
**Suggested fix**: When matching `order-by` expressions to aggregations, compare the inner field reference — not just the operator name. `["sum", ["field", 2649, ...]]` and `["sum", ["field", 2656, ...]]` are different aggregations and should be disambiguated by their field IDs. Only error when truly ambiguous (same operator on the same field).
### Priority
| Issue | Impact | Effort |
|-------|--------|--------|
| `field` two-arg with numeric table ID | **Medium** — inconsistent with `table`, forces name lookup | Low |
| `order-by` disambiguation for same-operator aggregations | **High** — forces dropping aggregations as workaround | Medium |
---
## Round 1: Stripe Dashboard (historical, most issues now resolved)
### Context
An AI agent attempted to build a full Stripe performance dashboard using MBQL queries via `construct-query`. While the task was completed, there were significant friction points that required reading source code, trial-and-error, and falling back to SQL for queries that should be expressible in MBQL.
### Issues (resolved)
The following issues from Round 1 have been addressed:
- ~~`asc`/`desc` helpers missing~~ — Added
- ~~No skill doc for `construct-query`~~ — MBQL construction reference now exists
- ~~Single-arity `field` not supported~~ — `(field "name")` now works (errors if ambiguous)
- ~~No `--run` flag for `construct-query`~~ — Added
- ~~`create-question` skill only showed SQL~~ — MBQL examples now documented
- ~~Aggregation column naming undocumented~~ — Naming convention now in docs
### Issues (still open from Round 1)
- **Accept numeric IDs in `table`/`field`**: `table` now accepts IDs, but `field` two-arg form does not (see Round 2 Issue 1 above)

44
agent-toolkit/dist/index.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
/**
* Typed API client. We use openapi-fetch for path/method safety,
* but relax params with `as any` since the generated spec marks many
* optional query params as required (a quirk of Metabase's spec generation).
*
* All methods return `{ data }` where data is cast to the expected type.
*/
interface MetabaseClient {
GET: (path: string, opts?: {
params?: Record<string, unknown>;
}) => Promise<{
data: unknown;
}>;
POST: (path: string, opts?: {
params?: Record<string, unknown>;
body?: unknown;
}) => Promise<{
data: unknown;
}>;
PUT: (path: string, opts?: {
params?: Record<string, unknown>;
body?: unknown;
}) => Promise<{
data: unknown;
}>;
DELETE: (path: string, opts?: {
params?: Record<string, unknown>;
}) => Promise<{
data: unknown;
}>;
}
interface OutputOptions {
fields?: string[];
maxRows?: number;
}
interface GlobalContext {
client: MetabaseClient;
outputOpts: OutputOptions;
dryRun: boolean;
}
export type { GlobalContext };

1377
agent-toolkit/dist/index.js vendored Executable file

File diff suppressed because it is too large Load Diff

1
agent-toolkit/dist/index.js.map vendored Normal file

File diff suppressed because one or more lines are too long

2
agent-toolkit/metabase-agent Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node --import tsx
import "./src/index.ts";

2460
agent-toolkit/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "@metabase/agent-toolkit",
"version": "0.1.0",
"description": "Agent-friendly CLI for Metabase — intent-oriented commands for discovering data, creating transforms, building dashboards",
"type": "module",
"bin": {
"metabase-agent": "./dist/index.js"
},
"scripts": {
"build:cljs": "cd .. && npx shadow-cljs release agent",
"build": "tsup",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"generate-types": "node scripts/fix-openapi.mjs && openapi-typescript openapi_fixed.json -o src/core/api-types.ts && rm openapi_fixed.json"
},
"dependencies": {
"commander": "^13.1.0",
"openapi-fetch": "^0.13.0",
"zod": "^3.24.0",
"zod-to-json-schema": "^3.24.0"
},
"devDependencies": {
"openapi-typescript": "^7.6.0",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0",
"@types/node": "^22.0.0"
},
"engines": {
"node": ">=18"
},
"files": [
"dist",
"SKILL.md"
]
}

View File

@@ -0,0 +1,26 @@
/**
* Fixes known issues in the Metabase OpenAPI spec that prevent
* openapi-typescript from generating types.
*
* Known issues:
* - Schema property key "nil%" contains a bare % that breaks decodeURIComponent
*/
import { readFileSync, writeFileSync } from "fs";
const inputPath = "../openapi_export.json";
const outputPath = "openapi_fixed.json";
const spec = JSON.parse(readFileSync(inputPath, "utf8"));
// Fix: rename "nil%" property key to "nil_pct"
const schema =
spec.components?.schemas?.[
"metabase.lib.schema.metadata.fingerprint..fingerprint.global"
];
if (schema?.properties?.["nil%"]) {
schema.properties["nil_pct"] = schema.properties["nil%"];
delete schema.properties["nil%"];
}
writeFileSync(outputPath, JSON.stringify(spec));
console.log("Fixed OpenAPI spec written to", outputPath);

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { resolveAuth, getAuthHeaders } from "../../core/auth.js";
describe("resolveAuth", () => {
const origEnv = { ...process.env };
beforeEach(() => {
delete process.env.METABASE_API_KEY;
delete process.env.METABASE_SESSION_TOKEN;
});
afterEach(() => {
process.env = { ...origEnv };
});
it("uses apiKey from opts", () => {
const auth = resolveAuth({ apiKey: "test-key" });
expect(auth.apiKey).toBe("test-key");
});
it("uses apiKey from env", () => {
process.env.METABASE_API_KEY = "env-key";
const auth = resolveAuth({});
expect(auth.apiKey).toBe("env-key");
});
it("uses sessionToken from opts", () => {
const auth = resolveAuth({ sessionToken: "tok" });
expect(auth.sessionToken).toBe("tok");
});
it("throws when no auth provided", () => {
expect(() => resolveAuth({})).toThrow("Authentication required");
});
});
describe("getAuthHeaders", () => {
it("returns X-Api-Key header", () => {
expect(getAuthHeaders({ apiKey: "key123" })).toEqual({
"X-Api-Key": "key123",
});
});
it("returns X-Metabase-Session header", () => {
expect(getAuthHeaders({ sessionToken: "sess" })).toEqual({
"X-Metabase-Session": "sess",
});
});
it("prefers apiKey over sessionToken", () => {
const headers = getAuthHeaders({
apiKey: "key",
sessionToken: "sess",
});
expect(headers).toEqual({ "X-Api-Key": "key" });
});
it("returns empty when no auth", () => {
expect(getAuthHeaders({})).toEqual({});
});
});

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { FieldResolver } from "../../core/field-resolver.js";
import type { MetabaseClient } from "../../core/client.js";
import { CliError } from "../../core/validation.js";
function mockClient(
fields: Array<{
id: number;
name: string;
display_name: string;
base_type: string;
semantic_type: string | null;
}>,
): MetabaseClient {
return {
GET: vi.fn().mockResolvedValue({
data: { fields },
}),
POST: vi.fn(),
PUT: vi.fn(),
DELETE: vi.fn(),
};
}
describe("FieldResolver", () => {
const fields = [
{
id: 10,
name: "created_at",
display_name: "Created At",
base_type: "type/DateTime",
semantic_type: null,
},
{
id: 20,
name: "total",
display_name: "Total Amount",
base_type: "type/Float",
semantic_type: null,
},
];
it("passes through numeric IDs", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
expect(await resolver.resolve(1, 42)).toBe(42);
expect(client.GET).not.toHaveBeenCalled();
});
it("passes through string numeric IDs", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
expect(await resolver.resolve(1, "42")).toBe(42);
expect(client.GET).not.toHaveBeenCalled();
});
it("resolves by field name", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
expect(await resolver.resolve(1, "created_at")).toBe(10);
});
it("resolves by display name", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
expect(await resolver.resolve(1, "Total Amount")).toBe(20);
});
it("resolves case-insensitively", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
expect(await resolver.resolve(1, "CREATED_AT")).toBe(10);
expect(await resolver.resolve(1, "total amount")).toBe(20);
});
it("throws CliError for unknown field with suggestion", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
try {
await resolver.resolve(1, "created");
expect.fail("should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(CliError);
const err = e as CliError;
expect(err.code).toBe("unknown_field");
expect(err.hint).toContain("created_at");
}
});
it("caches table metadata", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
await resolver.resolve(1, "total");
await resolver.resolve(1, "created_at");
expect(client.GET).toHaveBeenCalledTimes(1);
});
it("fetches separately for different tables", async () => {
const client = mockClient(fields);
const resolver = new FieldResolver(client);
await resolver.resolve(1, "total");
await resolver.resolve(2, "total");
expect(client.GET).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
applyFieldMask,
applyFieldMaskToArray,
truncateRows,
formatOutput,
} from "../../core/output.js";
describe("applyFieldMask", () => {
it("filters to specified fields", () => {
const data = { id: 1, name: "test", secret: "hidden" };
expect(applyFieldMask(data, ["id", "name"])).toEqual({
id: 1,
name: "test",
});
});
it("ignores non-existent fields", () => {
const data = { id: 1 };
expect(applyFieldMask(data, ["id", "missing"])).toEqual({ id: 1 });
});
});
describe("applyFieldMaskToArray", () => {
it("masks each item", () => {
const data = [
{ id: 1, name: "a", extra: true },
{ id: 2, name: "b", extra: false },
];
expect(applyFieldMaskToArray(data, ["id", "name"])).toEqual([
{ id: 1, name: "a" },
{ id: 2, name: "b" },
]);
});
});
describe("truncateRows", () => {
it("passes through when under limit", () => {
const rows = [[1], [2], [3]];
const result = truncateRows(rows, 10);
expect(result.rows).toEqual(rows);
expect(result.meta.truncated).toBe(false);
expect(result.meta.total_count).toBe(3);
expect(result.meta.returned_count).toBe(3);
});
it("truncates when over limit", () => {
const rows = [[1], [2], [3], [4], [5]];
const result = truncateRows(rows, 3);
expect(result.rows).toEqual([[1], [2], [3]]);
expect(result.meta.truncated).toBe(true);
expect(result.meta.total_count).toBe(5);
expect(result.meta.returned_count).toBe(3);
});
});
describe("formatOutput", () => {
it("outputs JSON", () => {
expect(formatOutput({ id: 1 })).toBe('{\n "id": 1\n}');
});
it("applies field mask when opts provided", () => {
const result = formatOutput(
{ id: 1, name: "test", secret: "x" },
{ fields: ["id", "name"] },
);
const parsed = JSON.parse(result);
expect(parsed).toEqual({ id: 1, name: "test" });
});
it("applies field mask to arrays", () => {
const result = formatOutput(
[{ id: 1, extra: true }, { id: 2, extra: false }],
{ fields: ["id"] },
);
const parsed = JSON.parse(result);
expect(parsed).toEqual([{ id: 1 }, { id: 2 }]);
});
});

View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import {
CreateQuestionInputSchema,
CreateDashboardInputSchema,
CreateSegmentInputSchema,
CreateTransformInputSchema,
ExecuteQueryInputSchema,
getSchemaForCommand,
listSchemaCommands,
} from "../../core/schemas.js";
describe("CreateTransformInputSchema", () => {
it("validates minimal input", () => {
const result = CreateTransformInputSchema.parse({
name: "Clean Orders",
database_id: 1,
sql: "SELECT * FROM orders",
target_table: "clean_orders",
});
expect(result.name).toBe("Clean Orders");
expect(result.run).toBeUndefined();
});
it("rejects missing required fields", () => {
expect(() =>
CreateTransformInputSchema.parse({ name: "Test" }),
).toThrow();
});
});
describe("CreateQuestionInputSchema", () => {
it("validates with defaults", () => {
const result = CreateQuestionInputSchema.parse({
name: "Revenue",
database_id: 1,
sql: "SELECT 1",
});
expect(result.display).toBe("table");
expect(result.type).toBe("question");
});
it("accepts visualization options", () => {
const result = CreateQuestionInputSchema.parse({
name: "Revenue",
database_id: 1,
sql: "SELECT 1",
display: "line",
visualization: {
x_axis: ["month"],
y_axis: ["revenue"],
show_values: true,
},
});
expect(result.visualization?.x_axis).toEqual(["month"]);
});
it("rejects invalid display type", () => {
expect(() =>
CreateQuestionInputSchema.parse({
name: "Test",
database_id: 1,
sql: "SELECT 1",
display: "invalid_type",
}),
).toThrow();
});
});
describe("CreateSegmentInputSchema", () => {
it("validates filter input", () => {
const result = CreateSegmentInputSchema.parse({
name: "Active Users",
table_id: 5,
filters: [
{ field: "last_login", op: "greater-than", value: "2024-01-01" },
],
});
expect(result.filters).toHaveLength(1);
expect(result.filters[0].field).toBe("last_login");
});
it("rejects empty filters", () => {
expect(() =>
CreateSegmentInputSchema.parse({
name: "Test",
table_id: 5,
filters: [],
}),
).toThrow();
});
});
describe("CreateDashboardInputSchema", () => {
it("validates minimal dashboard", () => {
const result = CreateDashboardInputSchema.parse({
name: "Sales Dashboard",
});
expect(result.name).toBe("Sales Dashboard");
expect(result.cards).toBeUndefined();
});
it("validates with cards and filters", () => {
const result = CreateDashboardInputSchema.parse({
name: "Sales",
cards: [{ card_id: 42, width: 6, height: 4 }],
filters: [
{
name: "Date Range",
type: "date/range",
targets: [{ card_id: 42, field: "created_at" }],
},
],
});
expect(result.cards).toHaveLength(1);
expect(result.filters).toHaveLength(1);
});
it("applies defaults for card dimensions", () => {
const result = CreateDashboardInputSchema.parse({
name: "Test",
cards: [{ card_id: 1 }],
});
expect(result.cards![0].width).toBe(6);
expect(result.cards![0].height).toBe(4);
});
});
describe("ExecuteQueryInputSchema", () => {
it("validates query input", () => {
const result = ExecuteQueryInputSchema.parse({
database_id: 1,
sql: "SELECT COUNT(*) FROM orders",
});
expect(result.database_id).toBe(1);
});
});
describe("schema introspection", () => {
it("lists available schemas", () => {
const schemas = listSchemaCommands();
expect(schemas).toContain("create-dashboard");
expect(schemas).toContain("create-question");
expect(schemas).toContain("execute-query");
});
it("returns JSON Schema for known command", () => {
const schema = getSchemaForCommand("create-dashboard");
expect(schema).toBeDefined();
expect(schema).toHaveProperty("definitions");
});
it("returns undefined for unknown command", () => {
expect(getSchemaForCommand("nonexistent")).toBeUndefined();
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import {
validatePositiveInt,
sanitizeString,
validateEnum,
CliError,
} from "../../core/validation.js";
describe("validatePositiveInt", () => {
it("accepts positive integers", () => {
expect(validatePositiveInt(1, "id")).toBe(1);
expect(validatePositiveInt(42, "id")).toBe(42);
});
it("parses string integers", () => {
expect(validatePositiveInt("5", "id")).toBe(5);
expect(validatePositiveInt("100", "id")).toBe(100);
});
it("rejects zero", () => {
expect(() => validatePositiveInt(0, "id")).toThrow(CliError);
});
it("rejects negative numbers", () => {
expect(() => validatePositiveInt(-1, "id")).toThrow(CliError);
});
it("rejects non-integers", () => {
expect(() => validatePositiveInt(1.5, "id")).toThrow(CliError);
expect(() => validatePositiveInt("abc", "id")).toThrow(CliError);
});
});
describe("sanitizeString", () => {
it("allows normal strings", () => {
expect(sanitizeString("hello world")).toBe("hello world");
});
it("allows newlines and tabs", () => {
expect(sanitizeString("line1\nline2\ttab")).toBe("line1\nline2\ttab");
});
it("rejects control characters", () => {
expect(() => sanitizeString("hello\x00world")).toThrow(CliError);
expect(() => sanitizeString("test\x07")).toThrow(CliError);
});
});
describe("validateEnum", () => {
const allowed = ["a", "b", "c"] as const;
it("accepts valid values", () => {
expect(validateEnum("a", allowed, "test")).toBe("a");
expect(validateEnum("c", allowed, "test")).toBe("c");
});
it("rejects invalid values", () => {
expect(() => validateEnum("d", allowed, "test")).toThrow(CliError);
});
});
describe("CliError", () => {
it("serializes to JSON with code and message", () => {
const err = new CliError("test_error", { message: "Something broke" });
expect(err.toJSON()).toEqual({
error: "test_error",
message: "Something broke",
});
});
it("includes hint when provided", () => {
const err = new CliError("test_error", {
message: "Not found",
hint: "Try this instead",
});
expect(err.toJSON()).toEqual({
error: "test_error",
message: "Not found",
hint: "Try this instead",
});
});
it("spreads details into JSON", () => {
const err = new CliError("test_error", {
message: "Not found",
details: { available_fields: ["a", "b"] },
});
const json = err.toJSON();
expect(json.available_fields).toEqual(["a", "b"]);
});
});

View File

@@ -0,0 +1,103 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult, formatQueryResult } from "../core/output.js";
import { CliError, validatePositiveInt } from "../core/validation.js";
import { fetchMetadataBundle } from "../core/metadata.js";
import { loadCljsModule } from "../core/cljs-bridge.js";
export function registerConstructQueryCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("construct-query")
.description(
"Construct an MBQL query by evaluating Clojure code with metabase.lib functions. Use --run to also execute.",
)
.requiredOption(
"--database-id <id>",
"Database ID",
)
.requiredOption(
"--clj <code>",
"Clojure code string using metabase.lib functions (query, table, field, filter, aggregate, breakout, etc.)",
)
.option(
"--run",
"Execute the constructed query and return results instead of the MBQL JSON",
)
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as {
databaseId: string;
clj: string;
run?: boolean;
};
const dbId = validatePositiveInt(options.databaseId, "database-id");
if (ctx.dryRun) {
const apiCalls = [
{
method: "GET",
path: `/api/database/${dbId}/metadata`,
description: "Fetch database metadata (tables + fields)",
},
];
if (options.run) {
apiCalls.push({
method: "POST",
path: "/api/dataset",
description: "Execute the constructed MBQL query",
});
}
printResult({
dry_run: true,
api_calls: apiCalls,
clj_code: options.clj,
note: options.run
? "Code will be evaluated and the query executed"
: "Code will be evaluated with metabase.lib functions after metadata fetch",
});
return;
}
// 1. Load CLJS module
const cljs = await loadCljsModule();
// 2. Fetch metadata from API
const metadata = await fetchMetadataBundle(ctx.client, dbId);
// 3. Evaluate Clojure code
let result: unknown;
try {
result = cljs.evaluate(options.clj, dbId, metadata);
} catch (e) {
throw new CliError("query_construction_error", {
message: e instanceof Error ? e.message : String(e),
hint: "Check your Clojure code syntax and function usage. Available functions: query, table, field, filter, aggregate, breakout, order-by, limit, asc, desc, and all metabase.lib operators.",
});
}
// 4. If --run, execute the query; otherwise return MBQL JSON
if (options.run) {
const apiPayload = {
...(result as Record<string, unknown>),
database: dbId,
};
const { data } = await ctx.client.POST("/api/dataset", {
body: apiPayload as never,
});
const maxRows = ctx.outputOpts.maxRows ?? 50;
printResult(
formatQueryResult(data as Record<string, unknown>, maxRows),
);
} else {
printResult(result);
}
}),
);
}

View File

@@ -0,0 +1,434 @@
import { randomUUID } from "crypto";
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { FieldResolver } from "../core/field-resolver.js";
import { printResult } from "../core/output.js";
import {
AddCardToDashboardInputSchema,
CreateDashboardInputSchema,
} from "../core/schemas.js";
import { CliError, validatePositiveInt } from "../core/validation.js";
const GRID_WIDTH = 24;
interface CardLayout {
card_id: number;
width: number;
height: number;
row: number;
col: number;
}
/**
* Auto-layout cards left-to-right, wrapping at grid width.
*/
function autoLayout(
cards: Array<{
card_id: number;
width: number;
height: number;
row?: number;
col?: number;
}>,
startRow = 0,
): CardLayout[] {
let currentRow = startRow;
let currentCol = 0;
let maxHeightInRow = 0;
return cards.map((card) => {
// If explicit position given, use it
if (card.row !== undefined && card.col !== undefined) {
return {
card_id: card.card_id,
width: card.width,
height: card.height,
row: card.row,
col: card.col,
};
}
// Auto-layout: wrap if card doesn't fit
if (currentCol + card.width > GRID_WIDTH) {
currentRow += maxHeightInRow;
currentCol = 0;
maxHeightInRow = 0;
}
const layout: CardLayout = {
card_id: card.card_id,
width: card.width,
height: card.height,
row: currentRow,
col: currentCol,
};
currentCol += card.width;
maxHeightInRow = Math.max(maxHeightInRow, card.height);
return layout;
});
}
/**
* Resolve a field name to a numeric field ID for a given card.
* Fetches the card to find its table_id, then resolves the field.
*/
async function resolveCardField(
ctx: GlobalContext,
resolver: FieldResolver,
cardId: number,
fieldName: string,
): Promise<number> {
// Get the card to find its source table
const { data } = await ctx.client.GET(`/api/card/${cardId}`);
const card = data as Record<string, unknown>;
const query = card.dataset_query as Record<string, unknown>;
// For native queries, we need the result_metadata to find field IDs
// For MBQL queries, we can get the source table
if (query?.type === "native") {
// For native queries, field mapping works differently
// We need to look at result_metadata for column names
const resultMeta = card.result_metadata as
| Array<Record<string, unknown>>
| undefined;
if (resultMeta) {
const col = resultMeta.find(
(c) =>
(c.name as string)?.toLowerCase() === fieldName.toLowerCase() ||
(c.display_name as string)?.toLowerCase() ===
fieldName.toLowerCase(),
);
if (col?.id && typeof col.id === "number") {
return col.id;
}
}
// For native SQL cards, return the field name as-is for dimension target
// Dashboard parameter mapping can use column name directly
throw new CliError("field_resolution", {
message: `Cannot resolve field '${fieldName}' on native SQL card ${cardId}`,
hint: "For SQL cards, dashboard filter mapping requires the card to have been run at least once so result metadata is available.",
});
}
// MBQL query - get source table
const innerQuery = query?.query as Record<string, unknown> | undefined;
const tableId = innerQuery?.["source-table"] as number | undefined;
if (!tableId) {
throw new CliError("field_resolution", {
message: `Cannot determine source table for card ${cardId}`,
});
}
return resolver.resolve(tableId, fieldName);
}
export function registerDashboardCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("create-dashboard")
.description(
"Create a dashboard with cards and filters in one step",
)
.requiredOption(
"--json <payload>",
"JSON input (see: schema create-dashboard)",
)
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { json: string };
const input = CreateDashboardInputSchema.parse(
JSON.parse(options.json),
);
// Step 1: Create the dashboard
const createPayload = {
name: input.name,
...(input.description && { description: input.description }),
...(input.collection_id && { collection_id: input.collection_id }),
};
// Build parameters from filters
const parameters = (input.filters ?? []).map((f) => ({
id: randomUUID().slice(0, 8),
name: f.name,
type: f.type,
slug: f.name.toLowerCase().replace(/\s+/g, "_"),
}));
if (ctx.dryRun) {
const layouts = autoLayout(
(input.cards ?? []).map((c) => ({
card_id: c.card_id,
width: c.width ?? 6,
height: c.height ?? 4,
row: c.row,
col: c.col,
})),
);
printResult({
dry_run: true,
api_calls: [
{
method: "POST",
path: "/api/dashboard",
body: { ...createPayload, parameters },
},
...(input.cards?.length
? [
{
method: "PUT",
path: "/api/dashboard/{id}",
body: {
dashcards: layouts.map((l) => ({
id: -1,
card_id: l.card_id,
size_x: l.width,
size_y: l.height,
row: l.row,
col: l.col,
})),
parameters,
},
},
]
: []),
],
});
return;
}
const { data: dashboard } = await ctx.client.POST("/api/dashboard", {
body: { ...createPayload, parameters } as never,
});
const dashboardResult = dashboard as Record<string, unknown>;
const dashboardId = dashboardResult.id as number;
// Step 2: Add cards if provided
if (input.cards?.length) {
const layouts = autoLayout(
input.cards.map((c) => ({
card_id: c.card_id,
width: c.width ?? 6,
height: c.height ?? 4,
row: c.row,
col: c.col,
})),
);
// Resolve filter field mappings
const resolver = new FieldResolver(ctx.client);
const dashcards = await Promise.all(
layouts.map(async (layout, _idx) => {
const parameterMappings: Array<Record<string, unknown>> = [];
// Wire filters to this card
if (input.filters) {
for (let fi = 0; fi < input.filters.length; fi++) {
const filter = input.filters[fi];
const target = filter.targets.find(
(t) => t.card_id === layout.card_id,
);
if (target) {
try {
const fieldId = await resolveCardField(
ctx,
resolver,
target.card_id,
target.field,
);
parameterMappings.push({
parameter_id: parameters[fi].id,
card_id: layout.card_id,
target: ["dimension", ["field", fieldId, null]],
});
} catch {
// For native SQL cards, use column name target
parameterMappings.push({
parameter_id: parameters[fi].id,
card_id: layout.card_id,
target: [
"dimension",
["field", target.field, { "base-type": "type/Text" }],
],
});
}
}
}
}
return {
id: -(layout.card_id), // negative = new dashcard
card_id: layout.card_id,
size_x: layout.width,
size_y: layout.height,
row: layout.row,
col: layout.col,
parameter_mappings: parameterMappings,
};
}),
);
await ctx.client.PUT(`/api/dashboard/${dashboardId}`, {
body: { dashcards, parameters } as never,
});
}
printResult({
id: dashboardId,
name: input.name,
cards_added: input.cards?.length ?? 0,
filters_added: input.filters?.length ?? 0,
});
}),
);
program
.command("add-card-to-dashboard")
.description("Add a card to an existing dashboard")
.requiredOption(
"--json <payload>",
"JSON input (see: schema add-card-to-dashboard)",
)
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { json: string };
const input = AddCardToDashboardInputSchema.parse(
JSON.parse(options.json),
);
// Get existing dashboard
const { data: dashboard } = await ctx.client.GET(
`/api/dashboard/${input.dashboard_id}`,
);
const dashData = dashboard as Record<string, unknown>;
const existingDashcards =
(dashData.dashcards as Array<Record<string, unknown>>) ?? [];
const existingParams =
(dashData.parameters as Array<Record<string, unknown>>) ?? [];
// Find next available position
let maxBottom = 0;
for (const dc of existingDashcards) {
const bottom =
((dc.row as number) ?? 0) + ((dc.size_y as number) ?? 4);
maxBottom = Math.max(maxBottom, bottom);
}
// Build parameter mappings
const parameterMappings: Array<Record<string, unknown>> = [];
if (input.filter_mappings) {
const resolver = new FieldResolver(ctx.client);
for (const mapping of input.filter_mappings) {
const param = existingParams.find(
(p) =>
(p.name as string)?.toLowerCase() ===
mapping.filter_name.toLowerCase(),
);
if (!param) {
throw new CliError("unknown_filter", {
message: `Filter '${mapping.filter_name}' not found on dashboard ${input.dashboard_id}`,
hint: `Available filters: ${existingParams.map((p) => p.name).join(", ")}`,
});
}
try {
const fieldId = await resolveCardField(
ctx,
resolver,
input.card_id,
mapping.field,
);
parameterMappings.push({
parameter_id: param.id,
card_id: input.card_id,
target: ["dimension", ["field", fieldId, null]],
});
} catch {
parameterMappings.push({
parameter_id: param.id,
card_id: input.card_id,
target: [
"dimension",
["field", mapping.field, { "base-type": "type/Text" }],
],
});
}
}
}
const newDashcard = {
id: -(input.card_id),
card_id: input.card_id,
size_x: input.width ?? 6,
size_y: input.height ?? 4,
row: maxBottom,
col: 0,
parameter_mappings: parameterMappings,
};
const allDashcards = [
...existingDashcards.map((dc) => ({
id: dc.id,
card_id: dc.card_id,
size_x: dc.size_x,
size_y: dc.size_y,
row: dc.row,
col: dc.col,
parameter_mappings: dc.parameter_mappings ?? [],
})),
newDashcard,
];
await ctx.client.PUT(`/api/dashboard/${input.dashboard_id}`, {
body: { dashcards: allDashcards } as never,
});
printResult({
dashboard_id: input.dashboard_id,
card_id: input.card_id,
position: { row: maxBottom, col: 0 },
});
}),
);
program
.command("get-dashboard <id>")
.description("Get a dashboard with all its cards")
.action(
withContext(async (ctx, id: unknown) => {
const dashId = validatePositiveInt(id, "dashboard id");
const { data } = await ctx.client.GET(
`/api/dashboard/${dashId}`,
);
const raw = data as Record<string, unknown>;
const dashcards =
(raw.dashcards as Array<Record<string, unknown>>) ?? [];
const compact = {
id: raw.id,
name: raw.name,
description: raw.description,
parameters: raw.parameters,
cards: dashcards.map((dc) => ({
dashcard_id: dc.id,
card_id: dc.card_id,
card_name: (dc.card as Record<string, unknown> | undefined)?.name,
size_x: dc.size_x,
size_y: dc.size_y,
row: dc.row,
col: dc.col,
})),
};
printResult(compact, ctx.outputOpts);
}),
);
}

View File

@@ -0,0 +1,84 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult } from "../core/output.js";
import { validatePositiveInt } from "../core/validation.js";
export function registerDatabaseCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("list-databases")
.description("List all databases")
.option("--include <value>", 'Include related data: "tables"')
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { include?: string };
const { data } = await ctx.client.GET("/api/database", {
params: { query: { include: options.include } },
});
const raw = data as { data?: unknown[] } | unknown[];
const databases = Array.isArray(raw)
? raw
: ((raw as Record<string, unknown>)?.data ?? []);
const compact = (databases as Record<string, unknown>[]).map((db) => ({
id: db.id,
name: db.name,
engine: db.engine,
...(db.tables
? {
tables: (db.tables as Record<string, unknown>[]).map((t) => ({
id: t.id,
name: t.name,
schema: t.schema,
})),
}
: {}),
}));
printResult(compact, ctx.outputOpts);
}),
);
program
.command("get-database <id>")
.description("Get database schema — tables, fields, types")
.action(
withContext(async (ctx, id: unknown) => {
const dbId = validatePositiveInt(id, "database id");
const { data } = await ctx.client.GET(
`/api/database/${dbId}/metadata`,
);
const raw = data as Record<string, unknown>;
const tables = (raw?.tables as Record<string, unknown>[]) ?? [];
const compact = {
id: raw.id,
name: raw.name,
engine: raw.engine,
tables: tables.map((t) => ({
id: t.id,
name: t.name,
schema: t.schema,
display_name: t.display_name,
fields: ((t.fields as Record<string, unknown>[]) ?? []).map(
(f) => ({
id: f.id,
name: f.name,
display_name: f.display_name,
base_type: f.base_type,
semantic_type: f.semantic_type,
}),
),
})),
};
printResult(compact, ctx.outputOpts);
}),
);
}

View File

@@ -0,0 +1,135 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult } from "../core/output.js";
import { validatePositiveInt } from "../core/validation.js";
export function registerFieldCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("get-field <id>")
.description(
"Get field metadata including fingerprint (min/max/avg for numbers, date ranges for dates, distinct count)",
)
.action(
withContext(async (ctx, id: unknown) => {
const fieldId = validatePositiveInt(id, "field id");
if (ctx.dryRun) {
printResult({
dry_run: true,
api_calls: [
{ method: "GET", path: `/api/field/${fieldId}` },
],
});
return;
}
const { data } = await ctx.client.GET(`/api/field/${fieldId}`);
const raw = data as Record<string, unknown>;
const fingerprint = raw.fingerprint as Record<string, unknown> | null;
const global = fingerprint?.global as Record<string, unknown> | null;
const typeInfo = fingerprint?.type as Record<string, unknown> | null;
// Build a compact fingerprint summary
const stats: Record<string, unknown> = {};
if (global) {
if (global["distinct-count"] != null)
stats.distinct_count = global["distinct-count"];
if (global["nil%"] != null) stats.null_percent = global["nil%"];
}
if (typeInfo) {
const numInfo = typeInfo["type/Number"] as Record<
string,
unknown
> | null;
if (numInfo) {
stats.min = numInfo.min;
stats.max = numInfo.max;
stats.avg = numInfo.avg;
if (numInfo.q1 != null) stats.q1 = numInfo.q1;
if (numInfo.q3 != null) stats.q3 = numInfo.q3;
if (numInfo.sd != null) stats.sd = numInfo.sd;
}
const dateInfo = typeInfo["type/DateTime"] as Record<
string,
unknown
> | null;
if (dateInfo) {
stats.earliest = dateInfo.earliest;
stats.latest = dateInfo.latest;
}
const textInfo = typeInfo["type/Text"] as Record<
string,
unknown
> | null;
if (textInfo) {
stats.average_length = textInfo["average-length"];
}
}
printResult(
{
id: raw.id,
name: raw.name,
display_name: raw.display_name,
table_id: raw.table_id,
base_type: raw.base_type,
effective_type: raw.effective_type,
semantic_type: raw.semantic_type,
has_field_values: raw.has_field_values,
fk_target_field_id: raw.fk_target_field_id ?? null,
description: raw.description ?? null,
...(Object.keys(stats).length > 0 && { fingerprint: stats }),
},
ctx.outputOpts,
);
}),
);
program
.command("get-field-values <id>")
.description(
"Get distinct values for a field — use to discover valid filter values (e.g., status codes, categories)",
)
.action(
withContext(async (ctx, id: unknown) => {
const fieldId = validatePositiveInt(id, "field id");
if (ctx.dryRun) {
printResult({
dry_run: true,
api_calls: [
{ method: "GET", path: `/api/field/${fieldId}/values` },
],
});
return;
}
const { data } = await ctx.client.GET(
`/api/field/${fieldId}/values`,
);
const raw = data as Record<string, unknown>;
const rawValues = (raw.values as unknown[][]) ?? [];
// Flatten 1-tuples to plain values, keep 2-tuples as [value, display_name]
const values = rawValues.map((tuple) =>
tuple.length === 1 ? tuple[0] : tuple,
);
printResult(
{
field_id: raw.field_id,
values,
has_more_values: raw.has_more_values ?? false,
},
ctx.outputOpts,
);
}),
);
}

View File

@@ -0,0 +1,119 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult } from "../core/output.js";
import { CreateMeasureInputSchema } from "../core/schemas.js";
import { validatePositiveInt } from "../core/validation.js";
export function registerMeasureCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("create-measure")
.description(
"Create a reusable measure (aggregation expression) on a table",
)
.requiredOption(
"--json <payload>",
"JSON input (see: schema create-measure)",
)
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { json: string };
const input = CreateMeasureInputSchema.parse(
JSON.parse(options.json),
);
const apiPayload = {
name: input.name,
table_id: input.table_id,
definition: input.definition,
...(input.description && { description: input.description }),
};
if (ctx.dryRun) {
printResult({
dry_run: true,
api_calls: [
{ method: "POST", path: "/api/measure", body: apiPayload },
],
});
return;
}
const { data } = await ctx.client.POST("/api/measure", {
body: apiPayload as never,
});
const result = data as Record<string, unknown>;
printResult({
id: result.id,
name: result.name,
table_id: result.table_id,
});
}),
);
program
.command("list-measures")
.description("List all measures")
.action(
withContext(async (ctx) => {
const { data } = await ctx.client.GET("/api/measure");
const raw = data as Record<string, unknown>[];
const compact = raw.map((m) => ({
id: m.id,
name: m.name,
table_id: m.table_id,
description: m.description ?? null,
}));
printResult(compact, ctx.outputOpts);
}),
);
program
.command("get-measure <id>")
.description("Get measure details")
.action(
withContext(async (ctx, id: unknown) => {
const measureId = validatePositiveInt(id, "measure id");
const { data } = await ctx.client.GET(
`/api/measure/${measureId}`,
);
printResult(data, ctx.outputOpts);
}),
);
program
.command("update-measure <id>")
.description("Update a measure")
.requiredOption("--json <payload>", "JSON input with fields to update")
.action(
withContext(async (ctx, id: unknown, opts: unknown) => {
const measureId = validatePositiveInt(id, "measure id");
const options = opts as { json: string };
const input = JSON.parse(options.json) as Record<string, unknown>;
const apiPayload: Record<string, unknown> = {
revision_message:
(input.revision_message as string) ?? "Updated via CLI",
};
if (input.name) apiPayload.name = input.name;
if (input.description !== undefined)
apiPayload.description = input.description;
if (input.definition) apiPayload.definition = input.definition;
const { data } = await ctx.client.PUT(
`/api/measure/${measureId}`,
{ body: apiPayload as never },
);
const result = data as Record<string, unknown>;
printResult({ id: result.id, name: result.name });
}),
);
}

View File

@@ -0,0 +1,57 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult, formatQueryResult } from "../core/output.js";
import { ExecuteQueryInputSchema } from "../core/schemas.js";
export function registerQueryCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("execute-query")
.description("Execute an ad-hoc SQL or MBQL query")
.requiredOption(
"--json <payload>",
"JSON input (see: schema execute-query)",
)
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { json: string };
const input = ExecuteQueryInputSchema.parse(
JSON.parse(options.json),
);
const apiPayload = input.sql
? {
database: input.database_id,
type: "native" as const,
native: { query: input.sql },
}
: {
...(input.query as Record<string, unknown>),
database: input.database_id,
};
if (ctx.dryRun) {
printResult({
dry_run: true,
api_calls: [
{ method: "POST", path: "/api/dataset", body: apiPayload },
],
});
return;
}
const { data } = await ctx.client.POST("/api/dataset", {
body: apiPayload as never,
});
const maxRows = ctx.outputOpts.maxRows ?? 50;
printResult(
formatQueryResult(data as Record<string, unknown>, maxRows),
);
}),
);
}

View File

@@ -0,0 +1,186 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult, truncateRows } from "../core/output.js";
import { CreateQuestionInputSchema } from "../core/schemas.js";
import { validatePositiveInt } from "../core/validation.js";
/**
* Translate simplified visualization options to Metabase visualization_settings.
*/
function buildVisualizationSettings(
viz: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!viz) return {};
const settings: Record<string, unknown> = {};
if (viz.x_axis) settings["graph.dimensions"] = viz.x_axis;
if (viz.y_axis) settings["graph.metrics"] = viz.y_axis;
if (viz.stack) settings["stackable.stack_type"] = viz.stack;
if (viz.show_values !== undefined)
settings["graph.show_values"] = viz.show_values;
if (viz.x_axis_label) settings["graph.x_axis.title_text"] = viz.x_axis_label;
if (viz.y_axis_label) settings["graph.y_axis.title_text"] = viz.y_axis_label;
if (viz.line_style) settings["line.style"] = viz.line_style;
if (viz.line_interpolation)
settings["line.interpolate"] = viz.line_interpolation;
// Pie chart
if (viz.dimension) settings["pie.dimension"] = viz.dimension;
if (viz.metric) settings["pie.metric"] = viz.metric;
if (viz.show_legend !== undefined)
settings["pie.show_legend"] = viz.show_legend;
// Map
if (viz.map_type) settings["map.type"] = viz.map_type;
if (viz.map_region) settings["map.region"] = viz.map_region;
return settings;
}
export function registerQuestionCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("create-question")
.description(
"Create a saved question (card) from SQL with visualization settings",
)
.requiredOption(
"--json <payload>",
"JSON input (see: schema create-question)",
)
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { json: string };
const input = CreateQuestionInputSchema.parse(
JSON.parse(options.json),
);
const vizSettings = buildVisualizationSettings(
input.visualization as Record<string, unknown> | undefined,
);
const datasetQuery = input.sql
? {
type: "native" as const,
database: input.database_id,
native: { query: input.sql },
}
: {
...(input.query as Record<string, unknown>),
database: input.database_id,
};
const apiPayload = {
name: input.name,
type: input.type,
dataset_query: datasetQuery,
display: input.display,
visualization_settings: vizSettings,
...(input.collection_id && { collection_id: input.collection_id }),
...(input.description && { description: input.description }),
};
if (ctx.dryRun) {
printResult({
dry_run: true,
api_calls: [
{ method: "POST", path: "/api/card", body: apiPayload },
],
});
return;
}
const { data } = await ctx.client.POST("/api/card", {
body: apiPayload as never,
});
const result = data as Record<string, unknown>;
printResult({
id: result.id,
name: result.name,
display: result.display,
type: result.type,
});
}),
);
program
.command("update-question <id>")
.description("Update a question's SQL, display, or visualization")
.requiredOption("--json <payload>", "JSON input with fields to update")
.action(
withContext(async (ctx, id: unknown, opts: unknown) => {
const cardId = validatePositiveInt(id, "question id");
const options = opts as { json: string };
const input = JSON.parse(options.json) as Record<string, unknown>;
const apiPayload: Record<string, unknown> = {};
if (input.name) apiPayload.name = input.name;
if (input.description !== undefined)
apiPayload.description = input.description;
if (input.display) apiPayload.display = input.display;
if (input.sql && input.database_id) {
apiPayload.dataset_query = {
type: "native",
database: input.database_id,
native: { query: input.sql },
};
}
if (input.visualization) {
apiPayload.visualization_settings = buildVisualizationSettings(
input.visualization as Record<string, unknown>,
);
}
const { data } = await ctx.client.PUT(`/api/card/${cardId}`, {
body: apiPayload as never,
});
const result = data as Record<string, unknown>;
printResult({ id: result.id, name: result.name });
}),
);
program
.command("run-question <id>")
.description("Run a saved question and return sample results")
.option("--max-rows <n>", "Max rows to return (default: 10)", "10")
.action(
withContext(async (ctx, id: unknown, opts: unknown) => {
const cardId = validatePositiveInt(id, "question id");
const options = opts as { maxRows?: string };
const maxRows = options.maxRows ? parseInt(options.maxRows, 10) : 10;
const { data } = await ctx.client.POST(
`/api/card/${cardId}/query`,
{},
);
const result = data as Record<string, unknown>;
const resultData = result.data as Record<string, unknown>;
const cols =
(resultData?.cols as Array<Record<string, unknown>>) ?? [];
const rows = (resultData?.rows as unknown[][]) ?? [];
const { rows: truncatedRows, meta } = truncateRows(rows, maxRows);
printResult({
card_id: cardId,
status: result.status,
columns: cols.map((c) => ({
name: c.name,
display_name: c.display_name,
base_type: c.base_type,
})),
rows: truncatedRows,
row_count: result.row_count,
running_time: result.running_time,
_meta: meta,
});
}),
);
}

View File

@@ -0,0 +1,34 @@
import type { Command } from "commander";
import { getSchemaForCommand, listSchemaCommands } from "../core/schemas.js";
import { printResult } from "../core/output.js";
export function registerSchemaCommand(program: Command) {
program
.command("schema [command]")
.description(
"Print JSON Schema for a command's input. Run without args to list available schemas.",
)
.action((command?: string) => {
if (!command) {
printResult({
available_schemas: listSchemaCommands(),
usage: "metabase-agent schema <command-name>",
});
return;
}
const schema = getSchemaForCommand(command);
if (!schema) {
const available = listSchemaCommands();
printResult({
error: "unknown_command",
message: `No schema found for command '${command}'`,
available_schemas: available,
});
process.exitCode = 1;
return;
}
printResult(schema);
});
}

View File

@@ -0,0 +1,58 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult } from "../core/output.js";
export function registerSearchCommand(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("search <query>")
.description("Search for tables, dashboards, cards, metrics, segments")
.option(
"--models <models>",
"Comma-separated entity types to filter (table,dashboard,card,metric,segment)",
)
.action(
withContext(async (ctx, query: unknown, opts: unknown) => {
const options = opts as { models?: string };
const params: Record<string, unknown> = {
q: String(query),
};
// Metabase expects repeated query params: ?models=card&models=dashboard
if (options.models) {
params.models = options.models.split(",").map((m) => m.trim());
}
const { data } = await ctx.client.GET("/api/search", {
params: { query: params },
});
// Compact output: only key fields for context window discipline
const raw = data as {
data?: Array<Record<string, unknown>>;
total?: number;
};
const items = (raw.data ?? (Array.isArray(raw) ? raw : [])).map(
(item: Record<string, unknown>) => ({
id: item.id,
name: item.name,
model: item.model,
description: item.description,
database_id: item.database_id,
table_id: item.table_id,
}),
);
printResult(
{
results: items,
_meta: { total_count: raw.total ?? items.length },
},
ctx.outputOpts,
);
}),
);
}

View File

@@ -0,0 +1,179 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { FieldResolver } from "../core/field-resolver.js";
import { printResult } from "../core/output.js";
import { CreateSegmentInputSchema } from "../core/schemas.js";
import { validatePositiveInt } from "../core/validation.js";
/** Map simplified filter ops to MBQL operators */
const OP_MAP: Record<string, string> = {
equals: "=",
"not-equals": "!=",
"greater-than": ">",
"greater-than-or-equal": ">=",
"less-than": "<",
"less-than-or-equal": "<=",
contains: "contains",
"not-contains": "does-not-contain",
"starts-with": "starts-with",
"ends-with": "ends-with",
"is-null": "is-null",
"is-not-null": "not-null",
"is-empty": "is-empty",
"is-not-empty": "not-empty",
};
/**
* Build an MBQL filter clause from simplified filter input.
*/
async function buildMbqlFilter(
resolver: FieldResolver,
tableId: number,
filters: Array<{
field: string;
op: string;
value?: string | number | boolean;
values?: (string | number)[];
}>,
): Promise<unknown[]> {
if (filters.length === 1) {
return await buildSingleFilter(resolver, tableId, filters[0]);
}
// Multiple filters → AND clause
const clauses = await Promise.all(
filters.map((f) => buildSingleFilter(resolver, tableId, f)),
);
return ["and", ...clauses];
}
async function buildSingleFilter(
resolver: FieldResolver,
tableId: number,
filter: {
field: string;
op: string;
value?: string | number | boolean;
values?: (string | number)[];
},
): Promise<unknown[]> {
const fieldId = await resolver.resolve(tableId, filter.field);
const mbqlOp = OP_MAP[filter.op] ?? filter.op;
const fieldRef = ["field", fieldId, null];
// Existence operators (no value)
if (
mbqlOp === "is-null" ||
mbqlOp === "not-null" ||
mbqlOp === "is-empty" ||
mbqlOp === "not-empty"
) {
return [mbqlOp, fieldRef];
}
if (filter.values) {
return [mbqlOp, fieldRef, ...filter.values];
}
return [mbqlOp, fieldRef, filter.value];
}
export function registerSegmentCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("create-segment")
.description("Create a reusable filter (segment) on a table")
.requiredOption(
"--json <payload>",
"JSON input (see: schema create-segment)",
)
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { json: string };
const input = CreateSegmentInputSchema.parse(
JSON.parse(options.json),
);
const resolver = new FieldResolver(ctx.client);
const mbqlFilter = await buildMbqlFilter(
resolver,
input.table_id,
input.filters,
);
const apiPayload = {
name: input.name,
table_id: input.table_id,
definition: {
filter: mbqlFilter,
},
...(input.description && { description: input.description }),
};
if (ctx.dryRun) {
printResult({
dry_run: true,
api_calls: [
{ method: "POST", path: "/api/segment", body: apiPayload },
],
});
return;
}
const { data } = await ctx.client.POST("/api/segment", {
body: apiPayload as never,
});
const result = data as Record<string, unknown>;
printResult({
id: result.id,
name: result.name,
table_id: result.table_id,
});
}),
);
program
.command("update-segment <id>")
.description("Update a segment")
.requiredOption("--json <payload>", "JSON input with fields to update")
.action(
withContext(async (ctx, id: unknown, opts: unknown) => {
const segmentId = validatePositiveInt(id, "segment id");
const options = opts as { json: string };
const input = JSON.parse(options.json) as Record<string, unknown>;
const apiPayload: Record<string, unknown> = {
revision_message: (input.revision_message as string) ?? "Updated via CLI",
};
if (input.name) apiPayload.name = input.name;
if (input.description !== undefined)
apiPayload.description = input.description;
if (input.filters && input.table_id) {
const resolver = new FieldResolver(ctx.client);
const mbqlFilter = await buildMbqlFilter(
resolver,
input.table_id as number,
input.filters as Array<{
field: string;
op: string;
value?: string | number | boolean;
values?: (string | number)[];
}>,
);
apiPayload.definition = { filter: mbqlFilter };
}
const { data } = await ctx.client.PUT(`/api/segment/${segmentId}`, {
body: apiPayload as never,
});
const result = data as Record<string, unknown>;
printResult({ id: result.id, name: result.name });
}),
);
}

View File

@@ -0,0 +1,45 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult } from "../core/output.js";
import { validatePositiveInt } from "../core/validation.js";
export function registerTableCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("get-table <id>")
.description("Get table details — fields with names, types, and FKs")
.action(
withContext(async (ctx, id: unknown) => {
const tableId = validatePositiveInt(id, "table id");
const { data } = await ctx.client.GET(
`/api/table/${tableId}/query_metadata`,
);
const raw = data as Record<string, unknown>;
const fields = (raw?.fields as Record<string, unknown>[]) ?? [];
const compact = {
id: raw.id,
name: raw.name,
display_name: raw.display_name,
schema: raw.schema,
db_id: raw.db_id,
description: raw.description,
fields: fields.map((f) => ({
id: f.id,
name: f.name,
display_name: f.display_name,
base_type: f.base_type,
semantic_type: f.semantic_type,
fk_target_field_id: f.fk_target_field_id ?? null,
})),
};
printResult(compact, ctx.outputOpts);
}),
);
}

View File

@@ -0,0 +1,260 @@
import type { Command } from "commander";
import type { GlobalContext } from "../index.js";
import { printResult } from "../core/output.js";
import { CreateTransformInputSchema } from "../core/schemas.js";
import { CliError, validatePositiveInt } from "../core/validation.js";
export function registerTransformCommands(
program: Command,
withContext: (
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) => (...args: unknown[]) => Promise<void>,
) {
program
.command("create-transform")
.description("Create a SQL or MBQL transform. Use --run to execute immediately.")
.requiredOption("--json <payload>", "JSON input (see: schema create-transform)")
.action(
withContext(async (ctx, opts: unknown) => {
const options = opts as { json: string };
const input = CreateTransformInputSchema.parse(
JSON.parse(options.json),
);
const sourceQuery = input.sql
? {
database: input.database_id,
type: "native" as const,
native: { query: input.sql },
}
: {
...(input.query as Record<string, unknown>),
database: input.database_id,
};
const apiPayload = {
name: input.name,
description: input.description ?? null,
source: {
type: "query" as const,
query: sourceQuery,
},
target: {
type: "table" as const,
name: input.target_table,
...(input.target_schema && { schema: input.target_schema }),
...(input.target_database_id && {
database: input.target_database_id,
}),
},
};
if (ctx.dryRun) {
printResult({
dry_run: true,
api_calls: [
{ method: "POST", path: "/api/transform", body: apiPayload },
...(input.run
? [
{
method: "POST",
path: "/api/transform/{id}/run",
note: "Would poll for completion",
},
]
: []),
],
});
return;
}
const { data: transform } = await ctx.client.POST("/api/transform", {
body: apiPayload as never,
});
const result = transform as Record<string, unknown>;
if (input.run && result.id) {
const transformId = result.id as number;
const { data: runResult } = await ctx.client.POST(
`/api/transform/${transformId}/run`,
);
const run = runResult as { run_id?: number };
if (run.run_id) {
// Poll until complete
const finalStatus = await pollTransformRun(
ctx,
run.run_id,
);
printResult({
transform: { id: result.id, name: result.name },
run: finalStatus,
});
return;
}
}
printResult({
id: result.id,
name: result.name,
source_type: result.source_type,
});
}),
);
program
.command("get-transform <id>")
.description("Get transform details — name, SQL query, target table, run status")
.action(
withContext(async (ctx, id: unknown) => {
const transformId = validatePositiveInt(id, "transform id");
const { data } = await ctx.client.GET(
`/api/transform/${transformId}`,
);
const raw = data as Record<string, unknown>;
const source = raw.source as Record<string, unknown> | undefined;
const sourceQuery = source?.query as Record<string, unknown> | undefined;
const nativeQuery = sourceQuery?.native as Record<string, unknown> | undefined;
const target = raw.target as Record<string, unknown> | undefined;
const lastRun = raw.last_run as Record<string, unknown> | null;
const compact = {
id: raw.id,
name: raw.name,
description: raw.description,
database_id: sourceQuery?.database ?? null,
sql: nativeQuery?.query ?? null,
target_table: target?.name ?? null,
target_schema: target?.schema ?? null,
last_run_status: lastRun?.status ?? null,
last_run_at: lastRun?.started_at ?? null,
};
printResult(compact, ctx.outputOpts);
}),
);
program
.command("list-transforms")
.description("List all transforms with latest run status")
.action(
withContext(async (ctx) => {
const { data } = await ctx.client.GET("/api/transform");
const raw = data as Record<string, unknown>[];
const compact = raw.map((t) => ({
id: t.id,
name: t.name,
source_type: t.source_type,
last_run_status: (t.last_run as Record<string, unknown> | null)
?.status ?? null,
}));
printResult(compact, ctx.outputOpts);
}),
);
program
.command("get-transform-run <runId>")
.description("Check transform execution status")
.action(
withContext(async (ctx, runId: unknown) => {
const id = validatePositiveInt(runId, "run id");
const { data } = await ctx.client.GET(
`/api/transform/run/${id}`,
);
printResult(data, ctx.outputOpts);
}),
);
program
.command("update-transform <id>")
.description("Update a transform")
.requiredOption("--json <payload>", "JSON input with fields to update")
.action(
withContext(async (ctx, id: unknown, opts: unknown) => {
const transformId = validatePositiveInt(id, "transform id");
const options = opts as { json: string };
const input = JSON.parse(options.json);
// Build API payload from simplified input
const apiPayload: Record<string, unknown> = {};
if (input.name) apiPayload.name = input.name;
if (input.description !== undefined)
apiPayload.description = input.description;
if (input.sql || input.query) {
const dbId = input.database_id;
if (!dbId) {
throw new CliError("missing_parameter", {
message:
"database_id is required when updating the source query",
hint: "Include database_id in the JSON payload",
});
}
const sourceQuery = input.sql
? {
database: dbId,
type: "native",
native: { query: input.sql },
}
: {
...input.query,
database: dbId,
};
apiPayload.source = {
type: "query",
query: sourceQuery,
};
}
if (input.target_table) {
apiPayload.target = {
type: "table",
name: input.target_table,
...(input.target_schema && { schema: input.target_schema }),
};
}
const { data } = await ctx.client.PUT(
`/api/transform/${transformId}`,
{ body: apiPayload as never },
);
const result = data as Record<string, unknown>;
printResult({ id: result.id, name: result.name });
}),
);
}
async function pollTransformRun(
ctx: GlobalContext,
runId: number,
maxWaitMs = 300_000,
): Promise<Record<string, unknown>> {
const start = Date.now();
let delayMs = 1000;
while (Date.now() - start < maxWaitMs) {
const { data } = await ctx.client.GET(
`/api/transform/run/${runId}`,
);
const run = data as Record<string, unknown>;
const status = run.status as string;
if (
status === "succeeded" ||
status === "failed" ||
status === "timeout" ||
status === "canceled"
) {
return run;
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs = Math.min(delayMs * 1.5, 10_000);
}
return { status: "polling_timeout", run_id: runId };
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
export interface AuthConfig {
apiKey?: string;
sessionToken?: string;
}
export function resolveAuth(opts: {
apiKey?: string;
sessionToken?: string;
}): AuthConfig {
const apiKey = opts.apiKey || process.env.METABASE_API_KEY;
const sessionToken = opts.sessionToken || process.env.METABASE_SESSION_TOKEN;
if (!apiKey && !sessionToken) {
throw new Error(
"Authentication required. Set METABASE_API_KEY or METABASE_SESSION_TOKEN environment variable, or pass --api-key or --session-token.",
);
}
return { apiKey, sessionToken };
}
export function getAuthHeaders(auth: AuthConfig): Record<string, string> {
if (auth.apiKey) {
return { "X-Api-Key": auth.apiKey };
}
if (auth.sessionToken) {
return { "X-Metabase-Session": auth.sessionToken };
}
return {};
}

View File

@@ -0,0 +1,81 @@
import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./api-types.js";
import { type AuthConfig, getAuthHeaders } from "./auth.js";
import { CliError } from "./validation.js";
export interface ClientConfig {
baseUrl: string;
auth: AuthConfig;
}
/**
* Typed API client. We use openapi-fetch for path/method safety,
* but relax params with `as any` since the generated spec marks many
* optional query params as required (a quirk of Metabase's spec generation).
*
* All methods return `{ data }` where data is cast to the expected type.
*/
export interface MetabaseClient {
GET: (path: string, opts?: { params?: Record<string, unknown> }) => Promise<{ data: unknown }>;
POST: (path: string, opts?: { params?: Record<string, unknown>; body?: unknown }) => Promise<{ data: unknown }>;
PUT: (path: string, opts?: { params?: Record<string, unknown>; body?: unknown }) => Promise<{ data: unknown }>;
DELETE: (path: string, opts?: { params?: Record<string, unknown> }) => Promise<{ data: unknown }>;
}
export function createMetabaseClient(config: ClientConfig): MetabaseClient {
const authHeaders = getAuthHeaders(config.auth);
const errorMiddleware: Middleware = {
async onResponse({ response }) {
if (!response.ok && response.status !== 202) {
let message: string;
try {
const body = await response.clone().json();
message =
typeof body === "object" && body !== null && "message" in body
? String(body.message)
: JSON.stringify(body);
} catch {
message = `HTTP ${response.status}`;
}
throw new CliError("api_error", {
message,
details: { status: response.status },
});
}
return response;
},
};
const inner = createClient<paths>({
baseUrl: config.baseUrl.replace(/\/+$/, ""),
headers: {
"Content-Type": "application/json",
...authHeaders,
},
});
inner.use(errorMiddleware);
// Wrap to relax strict param requirements and handle undefined data
const wrap = (method: "GET" | "POST" | "PUT" | "DELETE") =>
async (path: string, opts?: Record<string, unknown>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (inner as any)[method](path, opts);
if (result.error && !result.data) {
throw new CliError("api_error", {
message: typeof result.error === "object" && result.error?.message
? String(result.error.message)
: JSON.stringify(result.error),
});
}
return { data: result.data };
};
return {
GET: wrap("GET"),
POST: wrap("POST"),
PUT: wrap("PUT"),
DELETE: wrap("DELETE"),
};
}

View File

@@ -0,0 +1,32 @@
import { CliError } from "./validation.js";
interface AgentCljs {
evaluate: (
code: string,
databaseId: number,
metadata: unknown,
) => unknown;
}
let _module: AgentCljs | null = null;
/**
* Dynamically import the compiled CLJS module.
* Uses ESM import() since both the agent-toolkit and the CLJS output are ESM.
*/
export async function loadCljsModule(): Promise<AgentCljs> {
if (_module) return _module;
try {
// Dynamic import of the ESM CLJS module
const mod = await import("../../lib/cljs/agent.js");
_module = mod as AgentCljs;
return _module;
} catch (e) {
throw new CliError("cljs_not_built", {
message:
"CLJS module not found. Run 'bun run build:cljs' in the agent-toolkit directory first.",
hint: "The MBQL query builder requires the ClojureScript module to be compiled.",
});
}
}

View File

@@ -0,0 +1,126 @@
import type { MetabaseClient } from "./client.js";
import { CliError } from "./validation.js";
interface FieldInfo {
id: number;
name: string;
display_name: string;
base_type: string;
semantic_type: string | null;
}
/**
* Resolves human-readable field names to numeric field IDs.
* Caches table metadata to avoid redundant API calls within a session.
*/
export class FieldResolver {
private cache = new Map<number, FieldInfo[]>();
private client: MetabaseClient;
constructor(client: MetabaseClient) {
this.client = client;
}
/**
* Resolve a field reference to a numeric ID.
* Accepts: numeric ID (passthrough), field name string (looked up from table metadata).
*/
async resolve(tableId: number, fieldRef: string | number): Promise<number> {
if (typeof fieldRef === "number") return fieldRef;
// If it's a string that parses as a number, treat as numeric ID
const asNum = Number(fieldRef);
if (!isNaN(asNum) && Number.isInteger(asNum)) return asNum;
const fields = await this.getTableFields(tableId);
const match = fields.find(
(f) =>
f.name.toLowerCase() === fieldRef.toLowerCase() ||
f.display_name.toLowerCase() === fieldRef.toLowerCase(),
);
if (!match) {
const available = fields.map((f) => f.name);
// Simple did-you-mean: find closest by edit distance
const suggestion = findClosest(fieldRef, available);
throw new CliError("unknown_field", {
message: `Field '${fieldRef}' not found on table ${tableId}`,
hint: suggestion
? `Did you mean '${suggestion}'? Available fields: ${available.join(", ")}`
: `Available fields: ${available.join(", ")}`,
details: { available_fields: available },
});
}
return match.id;
}
async getTableFields(tableId: number): Promise<FieldInfo[]> {
if (this.cache.has(tableId)) {
return this.cache.get(tableId)!;
}
const { data } = await this.client.GET(
`/api/table/${tableId}/query_metadata`,
);
const raw = data as {
fields?: Array<{
id: number;
name: string;
display_name: string;
base_type: string;
semantic_type: string | null;
}>;
};
const fields: FieldInfo[] = (raw?.fields ?? []).map((f) => ({
id: f.id,
name: f.name,
display_name: f.display_name,
base_type: f.base_type,
semantic_type: f.semantic_type,
}));
this.cache.set(tableId, fields);
return fields;
}
}
/** Simple Levenshtein-based closest match */
function findClosest(
target: string,
candidates: string[],
): string | undefined {
const t = target.toLowerCase();
let best: string | undefined;
let bestDist = Infinity;
for (const c of candidates) {
const d = levenshtein(t, c.toLowerCase());
if (d < bestDist && d <= Math.max(t.length, c.length) * 0.5) {
bestDist = d;
best = c;
}
}
return best;
}
function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () =>
Array(n + 1).fill(0),
);
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
}
return dp[m][n];
}

View File

@@ -0,0 +1,71 @@
import type { MetabaseClient } from "./client.js";
/**
* Shape expected by metabase.lib.js.metadata/metadata-provider:
* {
* databases: { "1": { id: 1, name: "...", engine: "...", ... } },
* tables: { "5": { id: 5, name: "ORDERS", db_id: 1, ... } },
* fields: { "23": { id: 23, name: "STATUS", table_id: 5, ... } },
* metrics: {},
* segments: {},
* questions: {},
* }
*/
export interface MetadataBundle {
databases: Record<string, unknown>;
tables: Record<string, unknown>;
fields: Record<string, unknown>;
metrics: Record<string, unknown>;
segments: Record<string, unknown>;
questions: Record<string, unknown>;
}
/**
* Fetch database metadata from the API and reshape it into the format
* expected by metabase.lib.js.metadata/metadata-provider.
*
* Uses GET /api/database/:id/metadata which returns everything in one call.
*/
export async function fetchMetadataBundle(
client: MetabaseClient,
databaseId: number,
): Promise<MetadataBundle> {
const { data } = await client.GET(
`/api/database/${databaseId}/metadata`,
);
const raw = data as Record<string, unknown>;
const tables = (raw?.tables as Array<Record<string, unknown>>) ?? [];
const bundle: MetadataBundle = {
databases: {
[String(databaseId)]: {
id: databaseId,
name: raw.name,
engine: raw.engine,
features: raw.features ?? [],
...(raw.settings ? { settings: raw.settings } : {}),
},
},
tables: {},
fields: {},
metrics: {},
segments: {},
questions: {},
};
for (const table of tables) {
const tableId = String(table.id);
const fields = (table.fields as Array<Record<string, unknown>>) ?? [];
// Store table without nested fields (fields go in top-level map)
const { fields: _fields, ...tableWithoutFields } = table;
bundle.tables[tableId] = tableWithoutFields;
for (const field of fields) {
bundle.fields[String(field.id)] = field;
}
}
return bundle;
}

View File

@@ -0,0 +1,105 @@
export interface OutputOptions {
fields?: string[];
maxRows?: number;
}
export interface OutputMeta {
truncated: boolean;
total_count?: number;
returned_count?: number;
}
export function applyFieldMask(
data: Record<string, unknown>,
fields: string[],
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const field of fields) {
if (field in data) {
result[field] = data[field];
}
}
return result;
}
export function applyFieldMaskToArray(
data: Record<string, unknown>[],
fields: string[],
): Record<string, unknown>[] {
return data.map((item) => applyFieldMask(item, fields));
}
export function truncateRows(
rows: unknown[][],
maxRows: number,
): { rows: unknown[][]; meta: OutputMeta } {
const total = rows.length;
const truncated = total > maxRows;
return {
rows: truncated ? rows.slice(0, maxRows) : rows,
meta: {
truncated,
total_count: total,
returned_count: truncated ? maxRows : total,
},
};
}
export function formatQueryResult(
apiResult: Record<string, unknown>,
maxRows: number,
): Record<string, unknown> {
const resultData = apiResult.data as Record<string, unknown>;
const cols = (resultData?.cols as Array<Record<string, unknown>>) ?? [];
const rows = (resultData?.rows as unknown[][]) ?? [];
const { rows: truncatedRows, meta } = truncateRows(rows, maxRows);
return {
status: apiResult.status,
columns: cols.map((c) => ({
name: c.name,
display_name: c.display_name,
base_type: c.base_type,
})),
rows: truncatedRows,
row_count: apiResult.row_count,
running_time: apiResult.running_time,
_meta: meta,
};
}
export function formatOutput(data: unknown, opts?: OutputOptions): string {
if (opts?.fields && typeof data === "object" && data !== null) {
if (Array.isArray(data)) {
data = applyFieldMaskToArray(
data as Record<string, unknown>[],
opts.fields,
);
} else {
data = applyFieldMask(data as Record<string, unknown>, opts.fields);
}
}
return JSON.stringify(data, null, 2);
}
export function printResult(data: unknown, opts?: OutputOptions): void {
process.stdout.write(formatOutput(data, opts) + "\n");
}
export function printError(error: unknown): void {
if (error && typeof error === "object" && "toJSON" in error) {
process.stderr.write(
JSON.stringify((error as { toJSON: () => unknown }).toJSON(), null, 2) +
"\n",
);
} else if (error instanceof Error) {
process.stderr.write(
JSON.stringify({ error: "error", message: error.message }, null, 2) +
"\n",
);
} else {
process.stderr.write(JSON.stringify({ error: "error", message: String(error) }, null, 2) + "\n");
}
process.exitCode = 1;
}

View File

@@ -0,0 +1,267 @@
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// ----- CLI input schemas (simplified, agent-friendly) -----
export const SearchInputSchema = z.object({
query: z.string().describe("Search query string"),
models: z
.array(z.string())
.optional()
.describe("Filter by entity type: table, dashboard, card, metric, segment"),
});
export const CreateTransformInputSchema = z
.object({
name: z.string().min(1).describe("Transform name"),
database_id: z.number().int().positive().describe("Source database ID"),
sql: z
.string()
.min(1)
.optional()
.describe("SQL query for the transform source (mutually exclusive with 'query')"),
query: z
.record(z.unknown())
.optional()
.describe(
"MBQL query object from construct-query (mutually exclusive with 'sql')",
),
target_table: z.string().min(1).describe("Name for the output table"),
target_schema: z.string().optional().describe("Schema for the output table"),
target_database_id: z
.number()
.int()
.positive()
.optional()
.describe("Target database ID (defaults to source database)"),
description: z.string().optional().describe("Transform description"),
run: z
.boolean()
.optional()
.describe("If true, run the transform immediately after creation"),
})
.refine((data) => (data.sql != null) !== (data.query != null), {
message: "Exactly one of 'sql' or 'query' must be provided",
});
const VisualizationSchema = z
.object({
x_axis: z
.array(z.string())
.optional()
.describe("Column names for X axis (→ graph.dimensions)"),
y_axis: z
.array(z.string())
.optional()
.describe("Column names for Y axis (→ graph.metrics)"),
stack: z
.enum(["stacked", "normalized"])
.optional()
.describe("Stack type for bar/area charts"),
show_values: z
.boolean()
.optional()
.describe("Show values on chart"),
x_axis_label: z.string().optional().describe("X axis label"),
y_axis_label: z.string().optional().describe("Y axis label"),
line_style: z
.enum(["solid", "dashed", "dotted"])
.optional()
.describe("Line style"),
line_interpolation: z
.enum(["linear", "cardinal", "monotone"])
.optional()
.describe("Line interpolation"),
// Pie chart
dimension: z
.array(z.string())
.optional()
.describe("Pie chart: category columns"),
metric: z.string().optional().describe("Pie chart: value column"),
show_legend: z.boolean().optional().describe("Show legend"),
// Map
map_type: z.enum(["pin", "region", "grid"]).optional(),
map_region: z.string().optional(),
})
.optional()
.describe("Visualization settings (CLI translates to Metabase format)");
export const DISPLAY_TYPES = [
"line",
"bar",
"area",
"combo",
"scatter",
"waterfall",
"funnel",
"sankey",
"scalar",
"smartscalar",
"gauge",
"progress",
"table",
"pivot",
"pie",
"row",
"map",
"boxplot",
] as const;
export const CreateQuestionInputSchema = z
.object({
name: z.string().min(1).describe("Question name"),
database_id: z.number().int().positive().describe("Database ID"),
sql: z
.string()
.min(1)
.optional()
.describe("SQL query (for native questions — mutually exclusive with 'query')"),
query: z
.record(z.unknown())
.optional()
.describe(
"MBQL query object from construct-query (mutually exclusive with 'sql')",
),
display: z
.enum(DISPLAY_TYPES)
.default("table")
.describe("Visualization type"),
type: z
.enum(["question", "model", "metric"])
.default("question")
.describe("Card type (question, model, or metric)"),
visualization: VisualizationSchema,
collection_id: z
.number()
.int()
.positive()
.optional()
.describe("Collection ID"),
description: z.string().optional().describe("Question description"),
})
.refine((data) => (data.sql != null) !== (data.query != null), {
message: "Exactly one of 'sql' or 'query' must be provided",
});
const FilterSchema = z.object({
field: z.string().describe("Field name (resolved to ID automatically)"),
op: z.string().describe("Filter operation: equals, greater-than, contains, is-null, etc."),
value: z.union([z.string(), z.number(), z.boolean()]).optional(),
values: z.array(z.union([z.string(), z.number()])).optional(),
});
export const CreateSegmentInputSchema = z.object({
name: z.string().min(1).describe("Segment name"),
table_id: z.number().int().positive().describe("Table ID"),
filters: z
.array(FilterSchema)
.min(1)
.describe("Filter conditions using field names"),
description: z.string().optional().describe("Segment description"),
});
const DashboardCardSchema = z.object({
card_id: z.number().int().positive().describe("Question/card ID"),
width: z.number().int().positive().default(6).describe("Width in grid units (max 24)"),
height: z.number().int().positive().default(4).describe("Height in grid units"),
row: z.number().int().min(0).optional().describe("Row position (auto-calculated if omitted)"),
col: z.number().int().min(0).optional().describe("Column position (auto-calculated if omitted)"),
});
const DashboardFilterTargetSchema = z.object({
card_id: z.number().int().positive().describe("Card ID to wire this filter to"),
field: z.string().describe("Field name on the card's table (resolved automatically)"),
});
const DashboardFilterSchema = z.object({
name: z.string().describe("Filter label shown on dashboard"),
type: z
.string()
.describe("Filter type: date/range, category, number, string, id, etc."),
targets: z
.array(DashboardFilterTargetSchema)
.describe("Cards and fields this filter connects to"),
});
export const CreateDashboardInputSchema = z.object({
name: z.string().min(1).describe("Dashboard name"),
description: z.string().optional().describe("Dashboard description"),
collection_id: z.number().int().positive().optional().describe("Collection ID"),
cards: z
.array(DashboardCardSchema)
.optional()
.describe("Cards to place on the dashboard"),
filters: z
.array(DashboardFilterSchema)
.optional()
.describe("Dashboard filters wired to card fields"),
});
export const AddCardToDashboardInputSchema = z.object({
dashboard_id: z.number().int().positive().describe("Dashboard ID"),
card_id: z.number().int().positive().describe("Card/question ID to add"),
width: z.number().int().positive().default(6),
height: z.number().int().positive().default(4),
filter_mappings: z
.array(
z.object({
filter_name: z.string().describe("Name of existing dashboard filter"),
field: z.string().describe("Field name to wire the filter to"),
}),
)
.optional()
.describe("Wire this card to existing dashboard filters"),
});
export const ExecuteQueryInputSchema = z
.object({
database_id: z.number().int().positive().describe("Database ID"),
sql: z
.string()
.min(1)
.optional()
.describe("SQL query to execute (mutually exclusive with 'query')"),
query: z
.record(z.unknown())
.optional()
.describe(
"MBQL query object from construct-query (mutually exclusive with 'sql')",
),
})
.refine((data) => (data.sql != null) !== (data.query != null), {
message: "Exactly one of 'sql' or 'query' must be provided",
});
export const CreateMeasureInputSchema = z.object({
name: z.string().min(1).describe("Measure name"),
table_id: z.number().int().positive().describe("Table ID"),
definition: z
.record(z.unknown())
.describe("MBQL5 query definition with exactly one aggregation"),
description: z.string().optional().describe("Measure description"),
});
// ----- Schema registry for the `schema` command -----
const schemaRegistry: Record<string, z.ZodType> = {
search: SearchInputSchema,
"create-transform": CreateTransformInputSchema,
"create-question": CreateQuestionInputSchema,
"create-segment": CreateSegmentInputSchema,
"create-dashboard": CreateDashboardInputSchema,
"add-card-to-dashboard": AddCardToDashboardInputSchema,
"execute-query": ExecuteQueryInputSchema,
"create-measure": CreateMeasureInputSchema,
};
export function getSchemaForCommand(
commandName: string,
): object | undefined {
const schema = schemaRegistry[commandName];
if (!schema) return undefined;
return zodToJsonSchema(schema, { name: commandName, target: "openApi3" });
}
export function listSchemaCommands(): string[] {
return Object.keys(schemaRegistry);
}

View File

@@ -0,0 +1,63 @@
export function validatePositiveInt(
value: unknown,
name: string,
): number {
const num = typeof value === "string" ? parseInt(value, 10) : value;
if (typeof num !== "number" || !Number.isInteger(num) || num < 1) {
throw new CliError("invalid_parameter", {
message: `${name} must be a positive integer, got '${value}'`,
});
}
return num;
}
export function sanitizeString(input: string): string {
// Reject control characters (ASCII < 0x20) except newlines and tabs in SQL
const hasControlChars = /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(input);
if (hasControlChars) {
throw new CliError("invalid_input", {
message:
"Input contains control characters. Remove non-printable characters and try again.",
});
}
return input;
}
export function validateEnum<T extends string>(
value: unknown,
allowed: readonly T[],
name: string,
): T {
if (!allowed.includes(value as T)) {
throw new CliError("invalid_parameter", {
message: `Invalid ${name}: '${value}'`,
hint: `Valid values: ${allowed.join(", ")}`,
});
}
return value as T;
}
export class CliError extends Error {
code: string;
hint?: string;
details?: Record<string, unknown>;
constructor(
code: string,
opts: { message: string; hint?: string; details?: Record<string, unknown> },
) {
super(opts.message);
this.code = code;
this.hint = opts.hint;
this.details = opts.details;
}
toJSON() {
return {
error: this.code,
message: this.message,
...(this.hint && { hint: this.hint }),
...(this.details && { ...this.details }),
};
}
}

117
agent-toolkit/src/index.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Command } from "commander";
import { resolveAuth } from "./core/auth.js";
import { createMetabaseClient, type MetabaseClient } from "./core/client.js";
import { printError, printResult, type OutputOptions } from "./core/output.js";
import { CliError } from "./core/validation.js";
import { registerSearchCommand } from "./commands/search.js";
import { registerDatabaseCommands } from "./commands/database.js";
import { registerTableCommands } from "./commands/table.js";
import { registerTransformCommands } from "./commands/transform.js";
import { registerQuestionCommands } from "./commands/question.js";
import { registerSegmentCommands } from "./commands/segment.js";
import { registerDashboardCommands } from "./commands/dashboard.js";
import { registerQueryCommands } from "./commands/query.js";
import { registerConstructQueryCommands } from "./commands/construct-query.js";
import { registerMeasureCommands } from "./commands/measure.js";
import { registerFieldCommands } from "./commands/field.js";
import { registerSchemaCommand } from "./commands/schema-cmd.js";
export interface GlobalContext {
client: MetabaseClient;
outputOpts: OutputOptions;
dryRun: boolean;
}
function createContext(opts: {
url?: string;
apiKey?: string;
sessionToken?: string;
fields?: string;
maxRows?: string;
dryRun?: boolean;
}): GlobalContext {
const baseUrl = opts.url || process.env.METABASE_URL;
if (!baseUrl) {
throw new CliError("missing_config", {
message:
"Metabase URL required. Set METABASE_URL environment variable or pass --url.",
});
}
const auth = resolveAuth({
apiKey: opts.apiKey,
sessionToken: opts.sessionToken,
});
const client = createMetabaseClient({ baseUrl, auth });
const fields = opts.fields?.split(",").map((f) => f.trim());
const maxRows = opts.maxRows ? parseInt(opts.maxRows, 10) : 50;
return {
client,
outputOpts: { fields, maxRows },
dryRun: opts.dryRun ?? false,
};
}
const program = new Command();
program
.name("metabase-agent")
.description(
"Agent-friendly CLI for Metabase — discover data, create transforms, build dashboards",
)
.version("0.1.0")
.option("--url <url>", "Metabase instance URL (or METABASE_URL env)")
.option("--api-key <key>", "API key (or METABASE_API_KEY env)")
.option(
"--session-token <token>",
"Session token (or METABASE_SESSION_TOKEN env)",
)
.option("--fields <fields>", "Comma-separated field mask for response")
.option("--max-rows <n>", "Max rows in query results (default: 50)")
.option("--dry-run", "Show resolved API calls without executing");
// Wrap command actions to handle errors and create context
function withContext(
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
) {
return async (...args: unknown[]) => {
try {
const cmd = args[args.length - 1] as Command;
const globalOpts = cmd.optsWithGlobals();
const ctx = createContext(globalOpts);
await fn(ctx, ...args.slice(0, -1));
} catch (error) {
printError(error);
}
};
}
// Register all commands
registerSearchCommand(program, withContext);
registerDatabaseCommands(program, withContext);
registerTableCommands(program, withContext);
registerTransformCommands(program, withContext);
registerQuestionCommands(program, withContext);
registerSegmentCommands(program, withContext);
registerDashboardCommands(program, withContext);
registerQueryCommands(program, withContext);
registerConstructQueryCommands(program, withContext);
registerMeasureCommands(program, withContext);
registerFieldCommands(program, withContext);
registerSchemaCommand(program);
// Ping command (simple, inline)
program
.command("ping")
.description("Health check — verify connection to Metabase")
.action(
withContext(async (ctx) => {
const { data } = await ctx.client.GET("/api/health");
printResult(data);
}),
);
program.parse();

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/__tests__"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
banner: {
js: "#!/usr/bin/env node",
},
});

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["src/**/__tests__/**/*.test.ts"],
},
});

View File

@@ -146,6 +146,7 @@
org.clojure/core.match {:mvn/version "1.1.0"}
org.clojure/core.memoize {:mvn/version "1.1.266"} ; useful FIFO, LRU, etc. caching mechanisms
org.clojure/data.csv {:mvn/version "1.1.0"} ; CSV parsing / generation
org.clojure/data.xml {:mvn/version "0.2.0-alpha10"} ; XML parsing / generation
org.clojure/java.classpath {:mvn/version "1.1.0"} ; examine the Java classpath from Clojure programs
org.clojure/java.jdbc {:mvn/version "0.7.12"} ; basic JDBC access from Clojure
org.clojure/java.jmx {:mvn/version "1.1.0"} ; JMX bean library, for exporting diagnostic info
@@ -191,6 +192,7 @@
org.eclipse.jetty/jetty-unixdomain-server
org.eclipse.jetty.ee9/jetty-ee9-servlet
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server]}
selmer/selmer {:mvn/version "1.12.70"} ; Jinja-like templates for Clojure
slingshot/slingshot {:mvn/version "0.12.2"} ; enhanced throw/catch, used by other deps
software.amazon.awssdk/s3 {:mvn/version "2.40.13"} ; AWS SDK v2 for S3
software.amazon.awssdk/sts {:mvn/version "2.36.2"} ; Required for S3 client to use service account
@@ -444,6 +446,7 @@
cljs-bean/cljs-bean {:mvn/version "1.9.0"}
com.lambdaisland/glogi {:mvn/version "1.3.169"}
io.github.metabase/hawk {:mvn/version "1.0.13"}
org.babashka/sci {:mvn/version "0.10.49"} ; Small Clojure Interpreter for agent MBQL construction
org.clojars.mmb90/cljs-cache {:mvn/version "0.1.4"}
refactor-nrepl/refactor-nrepl {:mvn/version "3.11.0"}
;; keep it in sync with package.json
@@ -741,7 +744,7 @@
io.github.clojure/tools.build {:mvn/version "0.10.11"}
io.github.seancorfield/build-uber-log4j2-handler
{:git/tag "v2.25.1" :git/sha "1cfab45"} ; collect binary files for log4j2
org.clojure/data.xml {:mvn/version "0.2.0-alpha9"}
org.clojure/data.xml {:mvn/version "0.2.0-alpha10"}
org.clojure/tools.deps.alpha {:mvn/version "0.15.1254"}
org.fedorahosted.tennera/jgettext {:mvn/version "0.15.1"}}

View File

@@ -51,6 +51,7 @@
"Put everything needed for REPL development within easy reach"
(:require
[clojure.core.async :as a]
[clojure.core.memoize :as memoize]
[clojure.main]
[clojure.string :as str]
[clojure.test]
@@ -467,3 +468,9 @@
(mt/initialize-if-needed! :test-users)
;; seed test db
(mt/id))
(defn reset-static!
"Reset static and template caches to pick up new js"
[]
((requiring-resolve 'stencil.loader/invalidate-cache))
(memoize/memo-clear! @(requiring-resolve 'metabase.server.routes.index/load-inline-js)))

View File

@@ -421,6 +421,8 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
});
it("should load the embedded Metabot component", () => {
cy.request("PUT", "/api/setting/use-native-agent", { value: true });
H.visitCustomHtmlPage(`
${H.getNewEmbedScriptTag()}
${H.getNewEmbedConfigurationScript()}
@@ -432,7 +434,7 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
cy.findByText("Ask questions to AI.").should("be.visible");
cy.findByPlaceholderText("Ask AI a question...").type("Foo{enter}");
cy.findByText(
"Metabot is currently offline. Please try again later.",
"Sorry, an error occurred: No OpenRouter API key is set. If this persists, please contact your administrator.",
).should("be.visible");
cy.log(

View File

@@ -206,11 +206,11 @@ d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
H.lastChatMessage().should("have.text", "You, but don't tell anyone.");
H.mockMetabotResponse({ statusCode: 500 });
H.mockMetabotResponse({ statusCode: 200, body: apiKeyInvalidResponse });
H.sendMetabotMessage("Who is your favorite?");
H.lastChatMessage().should(
"have.text",
"Metabot is currently offline. Please try again later.",
"Sorry, an error occurred: Anthropic API key expired or invalid. If this persists, please contact your administrator.",
);
});
@@ -274,3 +274,6 @@ describe("Metabot in full-app embedding", () => {
const whoIsYourFavoriteResponse = `0:"You, but don't tell anyone."
2:{"type":"state","version":1,"value":{"queries":{}}}
d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`;
const apiKeyInvalidResponse = `3:"Anthropic API key expired or invalid"
d:{"finishReason":"error","usage":{}}`;

View File

@@ -85,7 +85,7 @@ describe(
H.activateToken("bleeding-edge");
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: SOURCE_TABLE });
cy.intercept("POST", "/api/ee/metabot-v3/agent-streaming").as("agentReq");
cy.intercept("POST", "/api/ee/metabot-v3/native-agent-streaming").as("agentReq");
cy.intercept("POST", "/api/transform").as("createTransform");
cy.intercept("PUT", "/api/transform/*").as("updateTransform");
});

View File

@@ -1,7 +1,8 @@
(ns metabase-enterprise.llm.settings
(:require
[clojure.string :as str]
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru]]))
[metabase.util.i18n :refer [deferred-tru tru]]))
(defsetting ee-openai-model
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
@@ -26,6 +27,14 @@
:export? false
:doc false)
(defsetting ee-anthropic-api-base-url
(deferred-tru "The OpenAI embeddings base URL used in Metabase Enterprise.")
:encryption :no
:visibility :settings-manager
:default "https://api.anthropic.com"
:export? false
:doc false)
(defsetting ee-anthropic-api-key
(deferred-tru "The Anthropic API Key used in Metabase Enterprise.")
:encryption :when-encryption-key-set
@@ -33,6 +42,70 @@
:export? false
:doc false)
(defsetting ee-openrouter-api-base-url
(deferred-tru "The OpenRouter API base URL used for Chat Completions.")
:encryption :no
:visibility :settings-manager
:default "https://openrouter.ai/api"
:export? false
:doc false)
(defsetting ee-openrouter-api-key
(deferred-tru "The OpenRouter API Key used in Metabase Enterprise.")
:encryption :when-encryption-key-set
:visibility :settings-manager
:export? false
:doc false)
(def ^:private supported-metabot-providers
"Set of supported LLM provider prefixes for the `ee-ai-metabot-provider` setting."
#{"anthropic" "openai" "openrouter"})
(defn- validate-metabot-provider!
"Validate that `value` has the format `provider/model` with a supported provider prefix.
Throws an exception with `:status-code 400` on invalid input."
[value]
(when-not (string? value)
(throw (ex-info (tru "Metabot provider must be a string, got: {0}" (pr-str value))
{:status-code 400})))
(let [[provider model] (str/split value #"/" 2)]
(when-not (contains? supported-metabot-providers provider)
(throw (ex-info (tru "Unknown provider {0}. Supported providers: {1}"
(pr-str provider) (str/join ", " (sort supported-metabot-providers)))
{:status-code 400
:provider provider
:supported supported-metabot-providers})))
(when (str/blank? model)
(throw (ex-info (tru "Model name is required. Expected format: provider/model, e.g. \"anthropic/claude-haiku-4-5\"")
{:status-code 400
:value value})))))
(defsetting ee-ai-metabot-provider
(deferred-tru "The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-4.1-mini`, `openrouter/anthropic/claude-haiku-4-5`.")
:type :string
:encryption :no
:default "openrouter/anthropic/claude-haiku-4-5"
:visibility :settings-manager
:export? false
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :ee-ai-metabot-provider new-value)))
(defsetting ee-ai-metabot-provider-lite
(deferred-tru "The AI provider and model for lightweight Metabot tasks and internal bookkeeping (e.g. user intent classification snowplow metrics).")
:type :string
:encryption :no
:default "openrouter/openai/gpt-oss-20b"
:visibility :settings-manager
:export? false
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :ee-ai-metabot-provider-lite new-value)))
(defsetting ee-ai-features-enabled
(deferred-tru "Enable AI features.")
:type :boolean

View File

@@ -0,0 +1,130 @@
(ns metabase-enterprise.metabot-v3.agent.analytics
"Snowplow and Prometheus analytics and intent-classification helpers for the metabot agent."
(:require
[clojure.string :as str]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.analytics.core :as analytics]
[metabase.api.common :as api]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; User intent classification
(def ^:private user-intents
{"create_or_modify_sql_query"
{:description "User wants to write new SQL from scratch or edit existing SQL"
:examples ["Write a SQL query to find all orders from last month."
"Edit the query to include customer email addresses (while looking at a SQL query)"]}
"fix_broken_sql_query"
{:description "User has broken SQL that needs fixing"
:examples ["My SQL query is giving an error, can you fix it?"
"Fix this query."]}
"query_data"
{:description "User wants to query data to get insights."
:examples ["Show me sales by region for Q1."
"How many users signed up last week?"]}
"data_discovery"
{:description "User looking for relevant tables/data or existing Metabase dashboards/questions"
:examples ["Which table has customer information?"
"What data can I use to analyze user behavior?"]}
"editing_visualizations"
{:description "User wants to build or edit visualizations"
:examples ["Create a bar chart for revenue by product."
"Turn this into a bar chart."]}
"interpret_results"
{:description "User needs help understanding data/charts"
:examples ["What does this chart mean?"
"Explain the results of this query."]}
"create_dashboard"
{:description "User wants to build dashboard"
:examples ["Build a dashboard for sales KPIs."
"Create a dashboard with multiple visualizations."]}
"navigation"
{:description "User wants to navigate to a specific Metabase UI location or feature"
:examples ["Take me to the SQL editor."
"Show me the admin settings."]}
"metabase_help"
{:description "User needs help using Metabase"
:examples ["How do I share a dashboard?"
"Help me set up a new database connection."
"How do I use custom expressions?"]}
"other"
{:description "User request doesn't fit standard categories"
:examples ["Any other request not covered above."]}})
(defn- render-user-intents-table
"Render user-intents as a plain-text table."
[]
(let [header "| Intent Category | Description | Examples |"
sep "|-----------------|-------------|----------|"
rows (map (fn [[intent {:keys [description examples]}]]
(str "| " intent " | " description " | " (str/join "; " examples) " |"))
user-intents)]
(str/join "\n" (concat [header sep] rows))))
(defn- build-intent-prompt [conversation-context]
(str "You are a user intent classification engine for requests to an AI agent operating on top of Metabase.\n"
"Given a conversation, classify the user's intent into one of the following categories:\n\n"
(render-user-intents-table)
"\n\nThis is the conversation context:\n"
"<conversation_context>\n"
conversation-context
"\n</conversation_context>\n\n"
"## Output format\n"
"Return the category wrapped in a <category> tag so that we can easily extract it from the string.\n"
"Only respond with the <category> tag including the intent category name - nothing else.\n\n"
"**Example:**\n"
"User: \"I want to see sales by region for Q1.\"\n"
"You: \"<category>query_data</category>\"\n\n"
"Classify the user's intent into one of the Intent Categories above.\n"))
(defn- classify-user-intent!
"Classify the user's message into one of the [[user-intent]] categories.
Returns the intent string.
Raises an exception if the classified intent is not in the known intents.
Uses plain-text extraction (no tool calls) so it works with models that don't support function calling."
[messages]
(let [context (json/encode (take-last 3 messages))
text (transduce (keep (fn [{:keys [type text]}] (when (= type :text) text)))
str
(self/call-llm (llm/ee-ai-metabot-provider-lite)
nil
[{:role "user" :content (build-intent-prompt context)}]
[]
;; Omit :request-id from tracking-opts to skip snowplow token_usage event
;; since this is for internal usage.
{:tag "user-intent-classification"}))
intent (second (re-find #"<category>([^<]+)</category>" text))]
(when-not (contains? user-intents intent)
(throw (ex-info (str "Classified intent '" intent "' not in known intents")
{:intent intent
:valid-intents (keys user-intents)})))
intent))
(defn- track-user-intent!
"Fire user_intent :snowplow/ai_service_event synchronously."
[intent tracking-opts]
(analytics/track-event!
:snowplow/ai_service_event
{:hashed-metabase-license-token (analytics/hashed-metabase-token-or-uuid)
:request-id (analytics/uuid->ai-service-hex-uuid (:request-id tracking-opts))
:source (:source tracking-opts)
:event "user_intent"
:user-id api/*current-user-id*
:session-id (:session-id tracking-opts)
:profile (some-> (:profile-id tracking-opts) name)
:event-details {"intent" intent}}))
(defn classify-and-track-user-intent-async!
"Classify and [[track-user-intent!]] in a background future."
[messages tracking-opts]
(future
(try
(-> messages
classify-user-intent!
(track-user-intent! tracking-opts))
(catch Exception e
(log/warn e "Failed to track user intent")))))

View File

@@ -0,0 +1,472 @@
(ns metabase-enterprise.metabot-v3.agent.core
"Main agent loop implementation using reducible streaming infrastructure."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.analytics :as agent-analytics]
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.agent.messages :as messages]
[metabase-enterprise.metabot-v3.agent.profiles :as profiles]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.analytics.prometheus :as prometheus]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[metabase.util.o11y :refer [with-span]]))
(set! *warn-on-reflection* true)
;;; Debug logging
;;
;; When `*debug-log*` is bound to an atom, the agent loop records full LLM
;; request/response data for every iteration. This is invaluable for debugging
;; benchmark failures — you can see exactly what the system prompt, message
;; history, and raw response were for each turn.
;;
;; Usage:
;; (binding [core/*debug-log* (atom [])]
;; (into [] (run-agent-loop opts))
;; @core/*debug-log*) ;; => [{:iteration 0 :request {...} :response [...]} ...]
;;
;; Or via the API with `"debug": true` in the request body, which emits the
;; debug log as a "debug_log" data part in the SSE stream.
(def ^:dynamic *debug-log*
"When bound to an atom, collects full LLM request/response data per iteration.
Each entry is a map with :iteration, :request, and :response keys.
The :request contains :model, :system, :messages (full history), and :tools.
The :response contains the collected parts from that iteration."
nil)
(defn- collect-text-from-parts
"Collect all text content from parts into a single string."
[parts]
(->> parts
(filter #(= :text (:type %)))
(map :text)
(str/join "")))
(defn- summarize-tool-ios
"Summarize tool inputs and outputs from parts."
[parts]
(let [inputs (filter #(= :tool-input (:type %)) parts)
outputs (filter #(= :tool-output (:type %)) parts)]
{:tool-calls (mapv (fn [p] {:id (:id p)
:function (:function p)
:arguments (:arguments p)})
inputs)
:tool-results (mapv (fn [p] {:id (:id p)
:output (let [r (:result p)]
(or (:output r) r))
:error (:error p)})
outputs)}))
(defn- debug-log!
"Append a debug entry if *debug-log* is bound."
[entry]
(when *debug-log*
(swap! *debug-log* conj entry)))
;;; Schemas
;;; Message schemas
;;
;; Input messages to the agent loop arrive in Chat Completions format (from the
;; API / frontend history). `messages.clj` converts them into AISDK parts.
;;
;; `:content` can be either a plain string or a sequential of content blocks
;; (backward-compat with Claude-format messages stored by the old Python AI
;; service).
(mr/def ::content-block
"A content block in a multi-part message (Claude format, backward-compat)."
[:map
[:type :string]]) ;; "text", "tool_use", "tool_result", etc.
(mr/def ::content
"Message content — string or sequential of content blocks."
[:or :string [:sequential ::content-block]])
(mr/def ::tool-call
"A tool call in an assistant message."
[:map
[:id :string]
[:name :string]
[:arguments [:or :string :map]]])
(mr/def ::user-message
"A user message: plain text or a sequence of tool_result content blocks."
[:map
[:role [:= :user]]
[:content ::content]])
(mr/def ::assistant-message
"An assistant message with optional text and/or tool calls."
[:map
[:role [:= :assistant]]
[:content {:optional true} [:maybe ::content]]
[:tool_calls {:optional true} [:maybe [:sequential ::tool-call]]]])
(mr/def ::system-message
"A system message."
[:map
[:role [:= :system]]
[:content :string]])
(mr/def ::tool-message
"A tool result message, referencing a previous tool call by ID."
[:map
[:role [:= :tool]]
[:tool_call_id :string]
[:content [:or :string :map]]])
(mr/def ::message
"A single message in the conversation history.
Discriminated by :role — each role has its own required/optional fields."
[:multi {:dispatch :role}
[:user ::user-message]
[:assistant ::assistant-message]
[:system ::system-message]
[:tool ::tool-message]])
(mr/def ::messages
"Sequence of messages in the conversation."
[:sequential ::message])
(mr/def ::state
"Agent state containing queries, charts, todos, and transforms."
[:map
[:queries {:optional true} [:map-of [:or :string :keyword] :map]]
[:charts {:optional true} [:map-of [:or :string :keyword] :map]]
[:todos {:optional true} [:sequential :map]]
[:transforms {:optional true} [:map-of [:or :string :keyword] :map]]])
(mr/def ::context
"Context information for the agent."
[:map-of :keyword :any])
(mr/def ::profile-id
"Profile identifier keyword."
[:enum :embedding_next :internal :transforms_codegen :sql :nlq :document-generate-content])
(mr/def ::tracking-opts
"Options for snowplow and prometheus analytics tracking."
[:map
[:session-id {:optional true} [:maybe ms/UUIDString]]
[:source {:optional true} [:maybe :string]]
[:tag {:optional true} [:maybe :string]]
[:track-user-intent? {:optional true} [:maybe :boolean]]])
;;; Iteration control
(defn- has-tool-calls?
"Check if any parts contain tool calls."
[parts]
(some #(= (:type %) :tool-input) parts))
(defn- has-final-response?
"Check if any tool result signals a final response."
[parts]
(some #(and (= (:type %) :tool-output)
(get-in % [:result :final-response?]))
parts))
(defn- should-continue?
"Determine if agent should continue iterating."
[iteration max-iterations parts]
(and (< iteration max-iterations)
(has-tool-calls? parts)
(not (has-final-response? parts))))
(defn- finish-reason
"Determine why the agent loop stopped."
[iteration max-iterations parts]
(cond
(>= iteration max-iterations) :max-iterations
(has-final-response? parts) :final-response
:else :stop))
;;; Call LLM
(defn- call-llm
"Call the LLM and stream processed parts.
Builds AISDK parts from memory and passes them to the adapter which converts
them to its native wire format."
[memory context profile tools iteration tracking-opts]
(let [model (:model profile)
system-msg (messages/build-system-message context profile tools)
input-parts (messages/build-message-history memory)]
(when *debug-log*
(debug-log! {:iteration iteration
:phase :request
:model model
:system (:content system-msg)
:parts input-parts
:tools (mapv (fn [[name _]] name) tools)}))
(eduction (streaming/post-process-xf (get-in memory [:state :queries] {})
(get-in memory [:state :charts] {}))
(self/call-llm model (:content system-msg) input-parts tools tracking-opts))))
;;; Memory management
(defn- get-structured-output [result]
(or (:structured-output result) (:structured_output result)))
(defn- extract-queries
"Extract and store queries from tool outputs."
[memory parts]
(transduce
(comp (filter #(= :tool-output (:type %)))
(map #(get-structured-output (:result %)))
(filter #(and (:query-id %) (:query %))))
(completing
(fn [mem {:keys [query-id query]}]
(memory/remember-query mem query-id query)))
memory
parts))
(defn- extract-charts
"Extract and store charts from tool outputs."
[memory parts]
(transduce
(comp (filter #(= :tool-output (:type %)))
(map #(get-structured-output (:result %)))
(filter #(and (:chart-id %) (:query-id %))))
(completing
(fn [mem {:keys [chart-id] :as chart}]
(memory/store-chart mem chart-id chart)))
memory
parts))
(defn- update-memory
"Update memory with parts from an iteration."
[memory parts]
(-> memory
(memory/add-step parts)
(extract-queries parts)
(extract-charts parts)))
;;; Context seeding
(defn- normalize-type [t]
(cond (keyword? t) (name t)
(string? t) t
:else (some-> t str)))
(defn- ensure-context-item-id
"Ensure a viewing context item has an :id, generating a UUID if missing.
The Python ai-service auto-generates UUIDs for context items; we match that behavior."
[item]
(cond-> item
(not (:id item)) (assoc :id (str (random-uuid)))))
(defn- assign-context-ids
"Assign IDs to all viewing context items that lack one.
Must be called before both seed-state and enrich-context-for-template
so they share consistent IDs."
[context]
(update context :user_is_viewing #(mapv ensure-context-item-id %)))
(defn- extract-query-from-context-item
"Extract [query-id query] from viewing context item."
[{:keys [id type query source] :as _item}]
(let [t (normalize-type type)]
(cond
(and (#{"adhoc" "native"} t) (map? query) id)
[(str id) query]
(and (= "transform" t)
(= "query" (normalize-type (:type source)))
(map? (:query source))
id)
[(str id) (:query source)])))
(defn- seed-state
"Seed state with queries from viewing context."
[state context]
(reduce (fn [s item]
(if-let [[qid q] (extract-query-from-context-item item)]
(assoc-in s [:queries qid] q)
s))
state
(:user_is_viewing context)))
;;; Main loop
(defn- init-agent
"Initialize agent state."
[{:keys [messages state profile-id context tracking-opts]}]
(let [context (assign-context-ids context)
profile (or (profiles/get-profile profile-id)
(throw (ex-info "Unknown profile" {:profile-id profile-id})))
capabilities (get context :capabilities #{})
base-tools (profiles/get-tools-for-profile profile-id capabilities)
seeded (seed-state (or state {}) context)
memory (-> (memory/initialize messages seeded context)
(memory/load-queries-from-state seeded)
(memory/load-charts-from-state seeded)
(memory/load-transforms-from-state seeded)
(memory/load-todos-from-state seeded))
memory-atom (atom memory)
tools (agent-tools/wrap-tools-with-state base-tools memory-atom)]
(log/info "Starting agent" {:profile profile-id
:tools (count tools)
:max-iter (:max-iterations profile)
:msgs (count messages)})
{:profile profile
:tools tools
:context context
:memory-atom memory-atom
:tracking-opts (merge {:profile-id profile-id
:request-id (str (random-uuid))
:source "metabot_agent"
:tag "agent"}
tracking-opts)}))
(defn- initial-loop-state
"Create initial loop state from agent config and reduction context."
[agent rf init usage-atom]
{:agent agent
:rf rf
:result init
:iteration 1
:status :continue
:usage-atom usage-atom})
(defn- final-state-part [memory]
{:type :data, :data-type "state", :version 1, :data (memory/get-state memory)})
(defn- error-part [^Exception e]
{:type :error, :error {:message (.getMessage e), :type (str (type e)), :data (ex-data e)}})
(defn- accumulate-usage-xf
"Transducer that merges each `:usage` part into the cumulative usage atom
(keyed by model) and replaces the part's `:usage` with the running total.
Non-usage parts pass through unchanged."
[usage-atom]
(map (fn [{:keys [usage model] :as part}]
(if (= (:type part) :usage)
(let [model (or model "unknown")]
(assoc part :usage
(-> (swap! usage-atom update model (partial merge-with +) usage)
(get model))))
part))))
(defn- loop-step
"Execute one iteration of the agent loop. Returns next loop state.
Streams parts to the consumer as they arrive while simultaneously accumulating
them for memory updates and control flow decisions."
[{:keys [agent rf result iteration usage-atom] :as loop-state}]
(with-span :debug {:name :metabot-v3.agent/loop-step
:iteration iteration}
(let [{:keys [profile tools context memory-atom tracking-opts]} agent
max-iter (:max-iterations profile 10)
tracking-opts (assoc tracking-opts :iteration iteration)
parts-atom (atom [])
llm-call (call-llm @memory-atom context profile tools iteration tracking-opts)
xf (comp (accumulate-usage-xf usage-atom)
(u/tee-xf parts-atom))
;; We use `reduce` instead of `transduce` because rf is the outer reducing
;; function (e.g. aisdk-line-xf wrapping streaming-writer-rf) whose completion
;; arity emits a finish message — that must only fire once, at the end of the
;; entire agent loop, not after every iteration.
result' (reduce (xf rf) result llm-call)
parts @parts-atom]
;; Capture response for debug log
(when *debug-log*
(debug-log! {:iteration iteration
:phase :response
:text (collect-text-from-parts parts)
:tools (summarize-tool-ios parts)
:all-parts parts}))
(log/debug "Iteration" {:n iteration :parts-count (count parts)})
(if (empty? parts)
(assoc loop-state :status :done :result (rf result (final-state-part @memory-atom)))
(do
(log/debug "Got parts" {:count (count parts) :types (mapv :type parts)})
(swap! memory-atom update-memory parts)
(cond
(reduced? result')
(assoc loop-state :status :reduced :result @result')
(should-continue? iteration max-iter parts)
(assoc loop-state :result result' :iteration (inc iteration))
:else
(do (log/info "Agent loop complete"
{:iterations iteration
;; TODO: decide if we want this reason to float up to frontend
:reason (finish-reason iteration max-iter parts)})
(assoc loop-state
:status :done
:result (rf result' (final-state-part @memory-atom))))))))))
;;; Public API
(defn- debug-log-part
"Create a data part containing the complete debug log.
Emitted at the end of the stream when debug mode is active."
[debug-log]
{:type :data
:data-type "debug_log"
:version 1
:data debug-log})
(mu/defn run-agent-loop
"Run agent loop, returning a reducible of parts.
When `:debug?` is true, binds `*debug-log*` and emits a `debug_log` data part
at the end of the stream with the complete LLM request/response data for every
iteration. This is useful for debugging benchmark failures.
Usage:
(into [] (run-agent-loop opts))
(transduce xf rf (run-agent-loop opts))
(into [] (run-agent-loop (assoc opts :debug? true))) ;; with debug log"
[opts :- [:map
[:messages ::messages]
[:profile-id ::profile-id]
[:state {:optional true} [:maybe ::state]]
[:context {:optional true} [:maybe ::context]]
[:tracking-opts {:optional true} [:maybe ::tracking-opts]]
[:debug? {:optional true} [:maybe :boolean]]]]
(let [profile-id (:profile-id opts)
debug? (:debug? opts)
track-user-intent? (some-> opts :tracking-opts :track-user-intent?)
labels {:profile-id (name profile-id)}]
(reify clojure.lang.IReduceInit
(reduce [_ rf init]
(with-span :info {:name :metabot-v3.agent/run-agent-loop
:profile-id profile-id
:msg-count (count (:messages opts))}
(prometheus/inc! :metabase-metabot/agent-requests labels)
(let [start-ms (u/start-timer)]
(binding [*debug-log* (when debug? (atom []))]
(try
(let [agent (init-agent opts)
_ (when track-user-intent?
(agent-analytics/classify-and-track-user-intent-async!
(:messages opts)
(:tracking-opts agent)))
{result :result
iteration :iteration} (->> (initial-loop-state agent rf init (atom {}))
(iterate loop-step)
(drop-while #(= :continue (:status %)))
first)]
(prometheus/observe! :metabase-metabot/agent-iterations labels iteration)
;; Emit debug log as a data part if debug mode was active
(if (and debug? (seq @*debug-log*))
(rf result (debug-log-part @*debug-log*))
result))
(catch Exception e
(prometheus/inc! :metabase-metabot/agent-errors labels)
(when-not (:api-error (ex-data e))
(log/error e "Agent loop error"))
(rf init (error-part e)))
(finally
(prometheus/observe! :metabase-metabot/agent-duration-ms labels (u/since-ms start-ms)))))))))))

View File

@@ -0,0 +1,187 @@
(ns metabase-enterprise.metabot-v3.agent.links
"Link resolution for metabase:// URIs in streaming text.
Converts internal metabase:// links to proper Metabase URLs using agent memory state."
(:require
[buddy.core.codecs :as codecs]
[clojure.string :as str]
[metabase.lib.core :as lib]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; Link Types
(def ^:private link-type-paths
"Map of metabase:// link types to their URL paths."
{"model" "/model"
"metric" "/metric"
"dashboard" "/dashboard"
"question" "/question"
"transform" "/admin/transforms"})
;;; Query/Chart URL Generation
(defn- query->url-hash
"Convert an MBQL query to a base64-encoded URL hash for /question# URLs."
[query]
;; Frontend /question# URLs require legacy MBQL format
#_{:clj-kondo/ignore [:discouraged-var]}
(let [dataset-query (if (and (map? query) (:lib/type query))
(lib/->legacy-MBQL query)
query)]
(-> {:dataset_query dataset-query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str)))
(defn- resolve-query-link
"Resolve a metabase://query/{id} link to a /question# URL."
[query-id queries-state]
(if-let [query (get queries-state query-id)]
(str "/question#" (query->url-hash query))
(do
(log/warn "Query not found for link resolution" {:query-id query-id
:available (keys queries-state)})
nil)))
(defn- resolve-chart-link
"Resolve a metabase://chart/{id} link to a proper URL.
Charts contain visualization settings and reference queries.
Falls back to treating the ID as a query ID if no chart is found
(handles LLM mistakes where it uses chart/ instead of query/)."
[chart-id charts-state queries-state]
(if-let [chart (get charts-state chart-id)]
;; Chart has a query-id that points to the actual query
(let [query-id (:query-id chart)]
(if-let [query (get queries-state query-id)]
(str "/question#" (query->url-hash query))
(do
(log/warn "Query not found for chart" {:chart-id chart-id
:query-id query-id})
nil)))
;; Chart not found - fall back to checking if it's actually a query ID
;; (LLM sometimes uses metabase://chart/ when it should use metabase://query/)
(if-let [query (get queries-state chart-id)]
(do
(log/debug "Treating chart link as query link" {:id chart-id})
(str "/question#" (query->url-hash query)))
(do
(log/warn "Chart not found for link resolution" {:chart-id chart-id
:available-charts (keys charts-state)
:available-queries (keys queries-state)})
nil))))
(defn- resolve-entity-link
"Resolve a metabase://{type}/{id} link to a proper URL."
[entity-type entity-id]
(if-let [path (get link-type-paths entity-type)]
(str path "/" entity-id)
(do
(log/warn "Unknown entity type for link" {:type entity-type :id entity-id})
nil)))
(defn- resolve-table-link
"Resolve a metabase://table/{id} link to a table page URL.
Note: This differs from ai-service (which links to /question#... by fetching database_id).
We avoid a DB lookup during streaming by linking directly to the table."
[table-id]
(let [parsed-id (cond
(int? table-id) table-id
(string? table-id) (when (re-matches #"\d+" table-id)
(parse-long table-id))
:else nil)]
(if-not parsed-id
(do
(log/warn "Invalid table id for link resolution" {:table-id table-id})
nil)
(str "/table/" parsed-id))))
;;; Main Link Resolution
(defn resolve-metabase-uri
"Resolve a metabase:// URI to a proper Metabase URL.
Supported URI formats:
- metabase://query/{uuid} - Links to query results
- metabase://chart/{uuid} - Links to chart visualizations
- metabase://question/{uuid} - Links to saved questions
- metabase://model/{id} - Links to models
- metabase://metric/{id} - Links to metrics
- metabase://dashboard/{id} - Links to dashboards
- metabase://table/{id} - Links to tables (as questions)
- metabase://transform/{id} - Links to transforms
Returns the resolved URL or nil if resolution fails."
[uri queries-state charts-state]
(when (and uri (str/starts-with? uri "metabase://"))
(let [path (subs uri 11) ; Remove "metabase://"
[entity-type entity-id] (str/split path #"/" 2)]
(when-not (or (str/blank? entity-type) (str/blank? entity-id))
(case entity-type
"query" (resolve-query-link entity-id queries-state)
"chart" (resolve-chart-link entity-id charts-state queries-state)
"question" (resolve-entity-link "question" entity-id)
"table" (resolve-table-link entity-id)
;; For other types, use simple path mapping
(resolve-entity-link entity-type entity-id))))))
;;; Markdown Link Processing
(def link-pattern
"Regex matching a complete markdown link [text](url)."
#"\[([^\[\]]*)\]\(([^()]*)\)")
(defn resolve-links
"Resolve all metabase:// links in text, replacing them with proper URLs.
Takes text containing markdown links like [Chart](metabase://chart/uuid)
and replaces metabase:// URLs with proper Metabase URLs.
Non-metabase:// links are preserved unchanged.
Returns the text with all resolvable links replaced."
[text queries-state charts-state]
(when (string? text)
(str/replace text link-pattern
(fn [[_ link-text url]]
(if (str/starts-with? url "metabase://")
(if-let [resolved (resolve-metabase-uri url queries-state charts-state)]
(str "[" link-text "](" resolved ")")
(do
(log/warn "Failed to resolve link URL" {:url url})
link-text))
(str "[" link-text "](" url ")"))))))
(defn resolve-links-xf
"Transducer that resolves metabase:// links in text parts.
Takes queries-state and charts-state for link resolution.
For :text parts, resolves metabase:// links to proper URLs.
For other part types, passes through unchanged."
[queries-state charts-state]
(map (fn [part]
(if (and (= (:type part) :text) (:text part))
(update part :text resolve-links queries-state charts-state)
part))))
;;; Part Processing (deprecated, use resolve-links-xf)
(defn process-part-links
"Process metabase:// links in a part's text content.
For :text parts, processes the text to resolve metabase:// links.
For other part types, returns unchanged.
Deprecated: Use resolve-links-xf transducer instead."
[part queries-state charts-state]
(if (and (= (:type part) :text) (:text part))
(update part :text resolve-links queries-state charts-state)
part))
(defn process-parts-links
"Process metabase:// links in all parts.
Returns parts with all text links resolved.
Deprecated: Use resolve-links-xf transducer instead."
[parts queries-state charts-state]
(into [] (resolve-links-xf queries-state charts-state) parts))

View File

@@ -0,0 +1,121 @@
(ns metabase-enterprise.metabot-v3.agent.markdown-link-buffer
"Streaming markdown link buffer for resolving metabase:// links.
Buffers markdown links during streaming to prevent incomplete markdown rendering
and resolves metabase:// protocol links to proper Metabase URLs.
Provides both a pure functional state machine and a transducer for stream processing."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.links :as links]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; State
(def initial-state
"Initial parser state."
{:buffer ""
:queries {}
:charts {}})
(defn with-context
"Update the queries and charts context in the state."
[state queries charts]
(assoc state :queries queries :charts charts))
;;; Buffering Logic
(defn- find-potential-link-start
"Find the index of a potential incomplete link start (unmatched '[').
Returns nil if no potential incomplete link exists."
[text]
(let [last-open (str/last-index-of text "[")]
(when last-open
;; Check if there's a complete link starting at or after this position
(let [suffix (subs text last-open)]
(when-not (re-find links/link-pattern suffix)
;; No complete link, but could be start of one if we have '[' without matching ']('...')'
;; or '[...](' without closing ')'
(when (or (re-find #"\[[^\]]*$" suffix) ; unclosed [
(re-find #"\[[^\]]*\]\([^)]*$" suffix)) ; unclosed (
last-open))))))
(defn step
"Process a chunk of text through the state machine.
Returns [new-state output-string]."
[{:keys [buffer] :as state} chunk]
(let [text (str buffer chunk)
;; First resolve any complete links
resolved (links/resolve-links text (:queries state) (:charts state))
;; Then check for incomplete link at the end
split-point (find-potential-link-start resolved)]
(if split-point
[(assoc state :buffer (subs resolved split-point))
(subs resolved 0 split-point)]
[(assoc state :buffer "")
resolved])))
(defn flush-state
"Flush remaining buffered content from state.
Returns the buffered content string."
[state]
(:buffer state ""))
;;; Transducer
(defn- get-structured-output
"Extract structured output from a tool result, handling key variations."
[result]
(or (:structured-output result)
(:structured_output result)))
(defn resolve-xf
"Stateful transducer that resolves metabase:// links in text parts.
This transducer:
1. Accumulates queries/charts from tool-output parts (via :structured-output)
2. Buffers markdown links to handle links split across text chunks
3. Resolves metabase:// URLs to proper Metabase paths
4. Flushes any remaining buffered content at stream end
Parameters:
- initial-queries: Initial map of query-id to query data
- initial-charts: Initial map of chart-id to chart data"
[initial-queries initial-charts]
(fn [rf]
(let [state (volatile! (with-context initial-state
(or initial-queries {})
(or initial-charts {})))
queries (volatile! (or initial-queries {}))
charts (volatile! (or initial-charts {}))]
(fn
([] (rf))
([result]
;; Flush any remaining buffered content
(let [flushed (flush-state @state)]
(if (seq flushed)
(rf (rf result {:type :text :text flushed}))
(rf result))))
([result part]
;; Accumulate state from tool outputs
(when (= (:type part) :tool-output)
(when-let [{:keys [query-id query chart-id]} (get-structured-output (:result part))]
(when (and query-id query)
(log/debug "Accumulating query in resolve-xf" {:query-id query-id})
(vswap! queries assoc query-id query))
(when (and chart-id query-id)
(log/debug "Accumulating chart in resolve-xf" {:chart-id chart-id})
(vswap! charts assoc chart-id (get-structured-output (:result part)))))
;; Update state with new context
(vswap! state with-context @queries @charts))
;; Process text parts through link buffer
(if (= (:type part) :text)
(let [[new-state processed-text] (step @state (:text part))]
(vreset! state new-state)
(if (seq processed-text)
(rf result (assoc part :text processed-text))
result))
(rf result part)))))))

View File

@@ -0,0 +1,172 @@
(ns metabase-enterprise.metabot-v3.agent.memory
"Memory and state management for the agent loop.
Tracks conversation history, tool execution steps, and conversation state."
(:require
[clojure.string :as str]))
(defn initialize
"Initialize memory from input messages and initial state.
State structure:
{:queries {query-id query-map}
:charts {chart-id chart-map}
:todos [...]
:transforms {...}}"
([messages state]
(initialize messages state nil))
([messages state context]
{:input-messages messages
:steps-taken []
:context context
:state (or state {:queries {} :charts {} :todos [] :transforms {}})}))
(defn add-step
"Add a completed agent step to memory.
A step includes the LLM response parts (text, tool calls, tool results)."
[memory parts]
(update memory :steps-taken conj {:parts parts}))
(defn get-state
"Extract conversation state from memory.
State includes queries, charts, todos, transforms, etc."
[memory]
(:state memory))
(defn update-state
"Update conversation state with new entities."
[memory state-updates]
(update memory :state merge state-updates))
(defn get-steps
"Get all steps taken by the agent so far."
[memory]
(:steps-taken memory))
(defn get-input-messages
"Get the original input messages."
[memory]
(:input-messages memory))
(defn iteration-count
"Get the number of iterations completed."
[memory]
(count (:steps-taken memory)))
;;; Query Management
(defn remember-query
"Store a query in memory state by its query-id.
Query should be an MBQL/MLv2 query map."
[memory query-id query]
(update-in memory [:state :queries] assoc query-id query))
(defn find-query
"Retrieve a query from memory state by its query-id.
Throws if query not found."
[memory query-id]
(let [queries (get-in memory [:state :queries] {})]
(if-let [query (get queries query-id)]
query
(throw (ex-info (str "Query with ID " query-id " not found in memory. "
"Available queries: [" (str/join ", " (keys queries)) "]. "
"Are you sure you were referring to a query ID?")
{:agent-error? true
:query-id query-id
:available-queries (keys queries)})))))
(defn get-queries
"Get all stored queries from memory state."
[memory]
(get-in memory [:state :queries] {}))
;;; Chart Management
(defn store-chart
"Store a chart configuration in memory state by its chart-id."
[memory chart-id chart]
(update-in memory [:state :charts] assoc chart-id chart))
(defn find-chart
"Retrieve a chart from memory state by its chart-id.
Throws if chart not found."
[memory chart-id]
(let [charts (get-in memory [:state :charts] {})]
(if-let [chart (get charts chart-id)]
chart
(throw (ex-info (str "Chart with ID " chart-id " not found in memory. "
"Available charts: [" (str/join ", " (keys charts)) "]")
{:agent-error? true
:chart-id chart-id
:available-charts (keys charts)})))))
(defn get-charts
"Get all stored charts from memory state."
[memory]
(get-in memory [:state :charts] {}))
;;; Transform Management
(defn remember-transform
"Store a transform in memory state by its ID."
[memory transform-id transform]
(update-in memory [:state :transforms] assoc (str transform-id) transform))
(defn find-transform
"Retrieve a transform from memory by its ID.
Throws if transform not found."
[memory transform-id]
(let [transforms (get-in memory [:state :transforms] {})]
(if-let [transform (get transforms (str transform-id))]
transform
(throw (ex-info (str "Transform with ID " transform-id " not found in memory. "
"Available transforms: [" (str/join ", " (keys transforms)) "]")
{:agent-error? true
:transform-id transform-id
:available-transforms (keys transforms)})))))
(defn get-transforms
"Get all stored transforms from memory state."
[memory]
(get-in memory [:state :transforms] {}))
;;; Todo Management
(defn set-todos
"Set the todo list in memory state.
Todos should be a vector of todo item maps."
[memory todos]
(assoc-in memory [:state :todos] (vec todos)))
(defn get-todos
"Get the current todo list from memory state."
[memory]
(get-in memory [:state :todos] []))
;;; State Loading
(defn load-queries-from-state
"Load queries from incoming state into memory."
[memory state]
(if-let [queries (:queries state)]
(reduce-kv remember-query memory queries)
memory))
(defn load-charts-from-state
"Load charts from incoming state into memory."
[memory state]
(if-let [charts (:charts state)]
(reduce-kv store-chart memory charts)
memory))
(defn load-transforms-from-state
"Load transforms from incoming state into memory."
[memory state]
(if-let [transforms (:transforms state)]
(reduce-kv remember-transform memory transforms)
memory))
(defn load-todos-from-state
"Load todos from incoming state into memory."
[memory state]
(if-let [todos (:todos state)]
(set-todos memory todos)
memory))

View File

@@ -0,0 +1,177 @@
(ns metabase-enterprise.metabot-v3.agent.messages
"Message formatting and history construction for the agent loop.
The agent stores conversation history as AISDK parts — the format-neutral
internal representation used throughout the agent loop. Each LLM adapter
is responsible for converting these parts into its own wire format (Claude
messages, Chat Completions messages, OpenAI Responses input items, etc.)."
(:require
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.agent.prompts :as prompts]
[metabase-enterprise.metabot-v3.agent.user-context :as user-context]
[metabase.util.json :as json]
[metabase.util.log :as log]))
;;; ──────────────────────────────────────────────────────────────────
;;; Input message → AISDK part normalisation
;;; ──────────────────────────────────────────────────────────────────
(defn- normalize-role
"Normalize role to keyword for comparison.
Handles both keyword roles (:user) and string roles (\"user\")."
[role]
(if (keyword? role)
role
(keyword role)))
(defn- decode-tool-arguments
"Decode tool call arguments from JSON when provided as a string.
Falls back to the raw string if decoding fails."
[arguments]
(if (string? arguments)
(try
(json/decode+kw arguments)
(catch Exception e
(log/warn e "Failed to decode tool call arguments" {:arguments arguments})
arguments))
arguments))
(defn- tool-result-content
"Normalise tool result content to a string suitable for :tool-output."
[content]
(cond
(string? content) content
(map? content) (or (:output content) (pr-str content))
:else (str content)))
;; TODO (Sanya 2026-02-17): when we will use stored messages for context for AI agents we should be able to
;; simplify (or even drop) this significantly if we're storing in the same AISDK parts format.
(defn input-message->parts
"Convert an input message (from the frontend / API) into a sequence of AISDK parts.
Supported input shapes:
{:role :user, :content \"...\"}
{:role :assistant, :content \"...\"}
{:role :assistant, :content \"...\", :tool_calls [...]}
{:role :assistant, :content [{:type \"tool_use\" ...}]}
{:role :tool, :tool_call_id \"...\", :content \"...\"}
Returns a sequence of AISDK parts (may be >1 for messages with tool calls)."
[{:keys [content tool_calls] :as msg}]
(let [role (normalize-role (:role msg))]
(case role
:user
;; User messages that contain tool_result content blocks (pre-formatted
;; from conversation history) are expanded into :tool-output parts.
(if (and (sequential? content)
(every? #(= "tool_result" (:type %)) content))
(mapv (fn [{:keys [tool_use_id content]}]
{:type :tool-output
:id tool_use_id
:result {:output (tool-result-content content)}})
content)
[{:role :user :content (if (sequential? content)
;; Mixed content blocks: join text parts
(->> content
(filter #(= "text" (:type %)))
(map :text)
(apply str))
(or content ""))}])
:assistant
;; Assistant messages may have text, tool_use content blocks, or
;; OpenAI-style :tool_calls. Normalise all into AISDK parts.
(let [ ;; Extract text — either plain string or from content blocks
text (cond
(string? content) content
(sequential? content) (let [texts (->> content
(filter #(= "text" (:type %)))
(map :text))]
(when (seq texts) (apply str texts))))
;; Extract tool calls — from content blocks or :tool_calls key
tool-inputs (cond
(sequential? content)
(->> content
(filter #(= "tool_use" (:type %)))
(mapv (fn [{:keys [id name input]}]
{:type :tool-input
:id id
:function name
:arguments (decode-tool-arguments input)})))
(seq tool_calls)
(mapv (fn [{:keys [id name arguments]}]
{:type :tool-input
:id id
:function name
:arguments (decode-tool-arguments arguments)})
tool_calls))]
(cond-> []
text (conj {:type :text :text text})
(seq tool-inputs) (into tool-inputs)))
:tool
[{:type :tool-output
:id (:tool_call_id msg)
:result {:output (tool-result-content content)}}]
;; system — pass through as-is (not really an AISDK part, but adapters
;; handle it separately via the :system option anyway)
:system
[{:role :system :content content}]
;; Fallback
(do
(log/warn "Unknown message role, passing through" {:role role})
[{:role role :content content}]))))
;;; ──────────────────────────────────────────────────────────────────
;;; History construction
;;; ──────────────────────────────────────────────────────────────────
(defn- step->parts
"Extract AISDK parts from a step, filtering out non-message types."
[step]
(->> (:parts step)
(filter #(#{:text :tool-input :tool-output} (:type %)))))
(defn build-message-history
"Build the conversation history as a flat sequence of AISDK parts.
Returns a vector of items that are either:
- `{:role :user, :content \"...\"}` for user messages
- `{:type :text, :text \"...\"}` for assistant text
- `{:type :tool-input, :id ..., :function ..., :arguments ...}` for tool calls
- `{:type :tool-output, :id ..., :result ...}` for tool results
Each LLM adapter converts this to its own wire format."
[memory]
(let [input-messages (memory/get-input-messages memory)
steps (memory/get-steps memory)
input-parts (mapcat input-message->parts input-messages)
step-parts (mapcat step->parts steps)
result (vec (concat input-parts step-parts))]
(log/info "Building message history"
{:input-message-count (count input-messages)
:step-count (count steps)
:total-parts (count result)})
result))
;;; ──────────────────────────────────────────────────────────────────
;;; System message
;;; ──────────────────────────────────────────────────────────────────
(defn build-system-message
"Build system message with templated prompt and enriched context.
Parameters:
- context: Context map from API (with user_is_viewing, user_recently_viewed, etc.)
- profile: Profile map with :prompt-template key
- tools: Tool registry map (name -> var)
Returns message map with {:role \"system\" :content \"...\"}."
[context profile tools]
(let [enriched-context (user-context/enrich-context-for-template context)
content (prompts/build-system-message-content profile enriched-context tools)]
{:role "system"
:content content}))

View File

@@ -0,0 +1,194 @@
(ns metabase-enterprise.metabot-v3.agent.profiles
"Profile configurations for different metabot use cases.
Each profile defines model settings, iteration limits, and available tools."
(:require
[clojure.string :as str]
[malli.error :as me]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(def ^:private tool-definition-schema
[:map
[:tool-name [:and :string [:fn #(not (str/blank? %))]]]
[:schema [:fn some?]]
[:doc {:optional true} [:maybe :string]]
[:prompt {:optional true} [:maybe :string]]])
(def ^:private *profiles
"Map of profile-id to profile configuration"
(atom {}))
(defn- validate-tool-definition!
[tool-var]
(let [definition (select-keys (meta tool-var) [:tool-name :schema :doc :prompt])]
(when-not (mr/validate tool-definition-schema definition)
(throw (ex-info "Invalid tool definition metadata"
{:tool tool-var
:definition definition
:errors (me/humanize (mu/explain tool-definition-schema definition))})))))
(mu/defn ^:private register-profile!
"Register new profile configuration.
Each profile includes:
- :name - Keyword identifier for the profile (e.g. :internal)
- :prompt-template - Selmer template name from resources/metabot/prompts/system/
- :max-iterations - Maximum agent loop iterations
- :temperature - LLM temperature setting
- :tools - Vector of tool vars available to this profile
Note: `:model` is resolved at runtime from the `ee-ai-metabot-provider` setting
(see [[get-profile]]), not stored in the profile."
[profile :- [:map
[:name :keyword]
[:prompt-template :string]
[:max-iterations :int]
[:temperature :float]
[:tools [:vector :any]]]]
(when-not (apply distinct? (map #(:tool-name (meta %)) (:tools profile)))
(let [dups (->> (frequencies (map #(:tool-name (meta %)) (:tools profile)))
(filter (fn [[_ cnt]] (< 1 cnt))))]
(throw (ex-info "Duplicate tool names in profile" {:tool-names (map first dups)}))))
(doseq [tool (:tools profile)]
(validate-tool-definition! tool))
(swap! *profiles assoc (:name profile) profile))
(register-profile!
{:name :embedding_next
:prompt-template "embedding-next.selmer"
:max-iterations 10
:temperature 0.3
:tools [#'agent-tools/construct-notebook-query-tool
#'agent-tools/read-resource-tool
#'agent-tools/list-available-data-sources-tool]})
(register-profile!
{:name :internal
:prompt-template "internal.selmer"
:max-iterations 10
:temperature 0.3
:tools [#'agent-tools/search-tool
#'agent-tools/construct-notebook-query-tool
#'agent-tools/read-resource-tool
#'agent-tools/create-sql-query-tool
#'agent-tools/edit-sql-query-tool
#'agent-tools/replace-sql-query-tool
#'agent-tools/edit-chart-tool
#'agent-tools/navigate-user-tool
#'agent-tools/create-chart-tool
#'agent-tools/create-autogenerated-dashboard-tool
#'agent-tools/create-dashboard-subscription-tool]})
(register-profile!
{:name :transforms_codegen
:prompt-template "transform-codegen.selmer"
:max-iterations 30
:temperature 0.3
:tools [#'agent-tools/transform-search-tool
#'agent-tools/get-transform-details-tool
#'agent-tools/get-transform-python-library-details-tool
#'agent-tools/write-transform-sql-tool
#'agent-tools/write-transform-python-tool
#'agent-tools/list-snippets-tool
#'agent-tools/get-snippet-details-tool
#'agent-tools/list-available-fields-tool
#'agent-tools/get-field-values-tool
#'agent-tools/todo-write-tool
#'agent-tools/todo-read-tool]})
(register-profile!
{:name :sql
:prompt-template "sql-querying-only.selmer"
:max-iterations 10
:temperature 0.3
:required-tool-call? true
:tools [#'agent-tools/sql-search-tool
#'agent-tools/read-resource-tool
#'agent-tools/create-sql-query-code-edit-tool
#'agent-tools/edit-sql-query-code-edit-tool
#'agent-tools/replace-sql-query-code-edit-tool
#'agent-tools/ask-for-sql-clarification-tool]})
(register-profile!
{:name :nlq
:prompt-template "natural-language-querying-only.selmer"
:max-iterations 10
:temperature 0.3
:tools [#'agent-tools/nlq-search-tool
#'agent-tools/read-resource-tool
#'agent-tools/construct-notebook-query-tool
#'agent-tools/navigate-user-tool
#'agent-tools/create-chart-tool
#'agent-tools/edit-chart-tool]})
(register-profile!
{:name :document-generate-content
:prompt-template "document-generate-content.selmer"
:max-iterations 10
:temperature 0.3
:required-tool-call? true
:tools [#'agent-tools/list-available-data-sources-tool
#'agent-tools/list-available-fields-tool
#'agent-tools/get-field-values-tool
#'agent-tools/document-schema-collect-tool
#'agent-tools/document-construct-model-chart-tool
#'agent-tools/document-construct-sql-chart-tool]})
(def ^:private api-string->capability-keyword
"Map from API capability strings (as sent by the frontend) to the keywords
used in tool :capabilities metadata. Keywords pass through unchanged."
{"frontend:navigate_user_v1" :frontend-navigate-user-v1
"permission:save_questions" :permission-save-questions
"permission:write_sql_queries" :permission-write-sql-queries
"permission:write_transforms" :permission-write-transforms
"feature:transforms" :feature-transforms
"feature:transforms-python" :feature-transforms-python
"feature:snippets" :feature-snippets
"automagic-dashboards" :automagic-dashboards})
(defn- capability->keyword
"Normalize a capability value to a keyword. Strings are looked up in the
api-string->capability-keyword map; keywords are returned as-is."
[cap]
(if (keyword? cap)
cap
(or (api-string->capability-keyword cap)
;; Fallback: keywordize unknown strings so new capabilities degrade
;; gracefully without requiring a map update.
(keyword (-> (str cap) (str/replace ":" "-") (str/replace "_" "-"))))))
(defn- filter-by-capabilities
"Filter tool vars by user capabilities.
Removes tools that require capabilities the user doesn't have.
Capabilities from the API arrive as strings (e.g. \"frontend:navigate_user_v1\")
while tool metadata uses keywords (e.g. :frontend-navigate-user-v1), so we
normalize to keywords before comparing."
[tool-vars capabilities]
(let [capabilities-set (into #{} (map capability->keyword) capabilities)]
(filter (fn [tool-var]
(every? capabilities-set (:capabilities (meta tool-var))))
tool-vars)))
(defn- tool-map
[tools]
(into {} (map (juxt #(:tool-name (meta %)) identity) tools)))
;;; API
(defn get-profile
"Get profile configuration by profile-id keyword.
The `:model` in the returned profile is resolved from the `ee-ai-metabot-provider`
setting at call time, so it always reflects the current admin configuration."
[profile-id]
(when-let [profile (get @*profiles profile-id)]
(assoc profile :model (llm/ee-ai-metabot-provider))))
(defn get-tools-for-profile
"Get tool registry filtered by profile configuration and user capabilities."
[profile-id capabilities]
(some-> (get-profile profile-id)
:tools
(filter-by-capabilities capabilities)
tool-map))

View File

@@ -0,0 +1,242 @@
(ns metabase-enterprise.metabot-v3.agent.prompts
"System for loading and rendering prompt templates using Selmer.
Handles:
- System prompt templates from resources/metabot/prompts/system/
- SQL dialect instructions from resources/metabot/prompts/dialects/
- Tool-specific prompts from resources/metabot/prompts/tools/
- Template rendering with context variables
- Template caching for performance"
(:require
[clojure.java.io :as io]
[metabase.util.log :as log]
[selmer.parser :as selmer]))
(set! *warn-on-reflection* true)
;;; Template Loading
(defn- load-resource
"Load a resource file as a string."
[resource-path]
(when-let [resource (io/resource resource-path)]
(slurp resource)))
(defn load-system-prompt-template
"Load a system prompt template from resources/metabot/prompts/system/.
Example: (load-system-prompt-template \"internal.selmer\")"
[template-name]
(let [path (str "metabot/prompts/system/" template-name)]
(or (load-resource path)
(do
(log/warn "System prompt template not found:" path)
nil))))
(defn load-dialect-instructions
"Load SQL dialect instructions from resources/metabot/prompts/dialects/.
Example: (load-dialect-instructions \"postgresql\")
Returns nil if dialect not found."
[dialect-name]
(when dialect-name
(let [path (str "metabot/prompts/dialects/" dialect-name ".md")]
(or (load-resource path)
(do
(log/debug "Dialect instructions not found:" path)
nil)))))
(defn load-tool-prompt-template
"Load a tool-specific prompt template from resources/metabot/prompts/tools/.
Example: (load-tool-prompt-template \"query-datasource/system.selmer\")"
[template-path]
(let [path (str "metabot/prompts/tools/" template-path)]
(or (load-resource path)
(do
(log/debug "Tool prompt template not found:" path)
nil))))
(defn load-llm-representations-template
"Load the LLM representations template from resources/metabot/prompts/."
[template-name]
(let [path (str "metabot/prompts/" template-name)]
(or (load-resource path)
(do
(log/warn "LLM representations template not found:" path)
nil))))
;;; Template Rendering
(defn render-system-prompt
"Render a system prompt template with context variables using Selmer.
Parameters:
- template: Template string (from load-system-prompt-template)
- context: Map of template variables
Common context variables:
- :current-time - User's current time string
- :first-day-of-week - Calendar week start (default \"Sunday\")
- :sql-dialect - SQL dialect name
- :sql-dialect-instructions - Dialect-specific guidance (markdown)
- :tool-instructions - Vector of tool instruction maps
- :viewing-context - Formatted user viewing context
- :recent-views - Formatted recent views"
[template context]
(try
(selmer/render template context)
(catch Exception e
(log/error e "Error rendering system prompt template")
;; Return template as-is on error
template)))
(defn render-tool-prompt
"Render a tool-specific prompt template with context variables.
Similar to render-system-prompt but for tool-level prompts."
[template context]
(try
(selmer/render template context)
(catch Exception e
(log/error e "Error rendering tool prompt template")
template)))
;;; Template Caching
(def ^:private template-cache
"In-memory cache for loaded templates.
Map of resource-path -> {:template string :loaded-at instant}"
(atom {}))
(defn- cache-key
"Generate cache key for a template."
[resource-type resource-name]
(str resource-type ":" resource-name))
(defn cached-load-template
"Load template with caching. Cache is simple in-memory map.
In development, you can clear the cache to reload templates:
(reset! metabot-v3.agent.prompts/template-cache {})"
[resource-type resource-name loader-fn]
(let [key (cache-key resource-type resource-name)]
(or (get @template-cache key)
(when-let [template (loader-fn resource-name)]
(let [entry {:template template
:loaded-at (java.time.Instant/now)}]
(swap! template-cache assoc key entry)
entry)))))
(defn get-cached-system-prompt
"Get system prompt template from cache or load it.
Returns template string or nil."
[template-name]
(:template (cached-load-template "system" template-name load-system-prompt-template)))
(defn get-cached-dialect-instructions
"Get dialect instructions from cache or load them.
Returns instructions string or nil."
[dialect-name]
(when dialect-name
(:template (cached-load-template "dialect" dialect-name load-dialect-instructions))))
(defn get-cached-tool-prompt
"Get tool prompt template from cache or load it.
Returns template string or nil."
[template-path]
(:template (cached-load-template "tool" template-path load-tool-prompt-template)))
(defn get-cached-llm-representations-template
"Get the LLM representations template from cache or load it."
[]
(:template (cached-load-template "llm-representations"
"llm_representations.selmer"
load-llm-representations-template)))
(defn clear-cache!
"Clear the template cache. Useful for development/testing."
[]
(reset! template-cache {}))
;;; Tool Instructions Extraction
(defn extract-tool-instructions
"Extract system instructions from tool definitions by loading prompt files.
For each tool, loads a markdown prompt file from `resources/metabot/prompts/tools/`.
The filename is determined by:
1. `:prompt` key in the tool's metadata (if present), or
2. `\"<tool-name>.md\"` as default.
Only tools with a corresponding prompt resource file are included.
Returns vector of maps: [{:tool_name \"search\" :instructions \"...\"}]"
[tools]
(vec
(for [[tool-name tool] tools
:let [fname (or (-> tool meta :prompt)
(str tool-name ".md"))
prompt (some-> (io/resource (str "metabot/prompts/tools/" fname))
slurp)]
:when prompt]
{:tool_name tool-name
:instructions prompt})))
;;; High-Level API
(defn build-system-message-content
"Build complete system message content from profile and context.
Parameters:
- profile: Profile map with :prompt-template key
- context: Agent context map with user info, viewing context, etc.
- tools: Tool registry map
Returns rendered system message string."
[{:keys [prompt-template]} context tools]
(let [template-name (or prompt-template "internal.selmer")
template (get-cached-system-prompt template-name)]
(if template
(let [sql-dialect (or (get context :sql_dialect)
(get context :sql-dialect))
dialect-instructions (when sql-dialect
(get-cached-dialect-instructions sql-dialect))
tool-instructions (extract-tool-instructions tools)
current-time (or (get context :current_time)
(get context :current-time))
first-day-of-week (or (get context :first_day_of_week)
(get context :first-day-of-week "Sunday"))
viewing-context (or (get context :viewing_context)
(get context :viewing-context))
recent-views (or (get context :recent_views)
(get context :recent-views))
template-context {:current_time current-time
:first_day_of_week first-day-of-week
:sql_dialect sql-dialect
:sql_dialect_instructions dialect-instructions
:tool_instructions tool-instructions
:viewing_context viewing-context
:recent_views recent-views}]
(render-system-prompt template template-context))
;; Fallback if template not found
(do
(log/error "System prompt template not found:" template-name)
"You are Metabot, a data analysis assistant for Metabase."))))
(comment
;; Development examples
;; Load and render a template
(def template (load-system-prompt-template "internal.selmer"))
(render-system-prompt template {:current-time "2024-01-15 14:30:00"
:sql-dialect "postgresql"})
;; Load dialect instructions
(load-dialect-instructions "postgresql")
;; Clear cache during development
(clear-cache!)
;; Extract tool instructions
(extract-tool-instructions {"search" #'some-tool-var}))

View File

@@ -0,0 +1,147 @@
(ns metabase-enterprise.metabot-v3.agent.streaming
"Streaming helpers for the agent loop.
Provides utilities for creating AI-SDK data parts, including navigation,
and transducers for stream processing."
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.agent.markdown-link-buffer :as markdown-link-buffer]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
;;; AI-SDK Data Types
;;
;; These match the Python AI Service's AISDKDataTypes enum
(def navigate-to-type "AI-SDK data type for navigation links." "navigate_to")
(def state-type "AI-SDK data type for state updates." "state")
(def todo-list-type "AI-SDK data type for todo lists." "todo_list")
(def code-edit-type "AI-SDK data type for code edits." "code_edit")
(def transform-suggestion-type "AI-SDK data type for transform suggestions." "transform_suggestion")
;;; Query URL Encoding
(defn query->url-hash
"Convert an MLv2/MBQL query to a base64-encoded URL hash.
Used for /question# URLs."
[query]
(-> {:dataset_query query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str))
(defn query->question-url
"Convert a query to a /question# URL."
[query]
(str "/question#" (query->url-hash query)))
;;; Data Part Constructors
(defn navigate-to-part
"Create a NAVIGATE_TO data part for streaming.
The URL should be a path like '/question#...' or '/model/123'.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.NAVIGATE_TO, version=1, value=path)"
[url]
{:type :data
:data-type navigate-to-type
:data url})
(defn state-part
"Create a STATE data part for streaming."
[state-map]
{:type :data
:data-type state-type
:data state-map})
(defn todo-list-part
"Create a TODO_LIST data part for streaming.
Todos should be a vector of todo item maps with :id, :content, :status, :priority keys.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.TODO_LIST, version=1, value=todos)"
[todos]
{:type :data
:data-type todo-list-type
:version 1
:data todos})
(defn code-edit-part
"Create a CODE_EDIT data part for streaming.
Edit-data should be a map describing the code edit operation.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.CODE_EDIT, version=1, value=edit_data)"
[edit-data]
{:type :data
:data-type code-edit-type
:version 1
:data edit-data})
(defn transform-suggestion-part
"Create a TRANSFORM_SUGGESTION data part for streaming.
Suggestion should be a map containing the suggested transform definition.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.TRANSFORM_SUGGESTION, version=1, value=suggestion)"
[suggestion]
{:type :data
:data-type transform-suggestion-type
:version 1
:data suggestion})
;;; Reaction Conversion
(defn reactions->data-parts
"Convert tool reactions to AI-SDK data parts for streaming.
Reactions are metadata returned by tools that trigger side effects:
- :metabot.reaction/redirect -> navigate_to data part
Returns a vector of data parts (may be empty if no relevant reactions)."
[reactions]
(into []
(keep (fn [{:keys [type url]}]
(case type
:metabot.reaction/redirect (navigate-to-part url)
nil)))
reactions))
;;; Stream Processing Transducers
(def expand-reactions-xf
"Stateless transducer that expands :reactions from tool-output parts into data parts.
Passes through all parts unchanged, then appends any reaction data parts after tool-output parts."
(mapcat (fn [part]
(if (= (:type part) :tool-output)
(cons part (reactions->data-parts (get-in part [:result :reactions])))
[part]))))
(def expand-data-parts-xf
"Stateless transducer that expands :data-parts from tool-output results.
Passes through all parts unchanged, then appends any data parts after tool-output parts."
(mapcat (fn [part]
(if (= (:type part) :tool-output)
(cons part (get-in part [:result :data-parts]))
[part]))))
(defn post-process-xf
"Composed transducer for post-processing agent output.
Applies in order:
1. expand-data-parts-xf - Extract data-parts from tool outputs
2. expand-reactions-xf - Extract reactions from tool outputs as data parts
3. resolve-links-xf - Resolve metabase:// links in text parts
Note: expand-data-parts-xf comes first so its output appears before reactions,
matching the original stream-parts-to-output! behavior.
Parameters:
- initial-queries: Initial map of query-id to query data
- initial-charts: Initial map of chart-id to chart data"
[initial-queries initial-charts]
(comp
expand-data-parts-xf
expand-reactions-xf
(markdown-link-buffer/resolve-xf initial-queries initial-charts)))

View File

@@ -0,0 +1,106 @@
(ns metabase-enterprise.metabot-v3.agent.tools
"Tool registry for the agent loop.
Provides a unified namespace for tool vars and state-aware wrapping."
(:require
[metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard :as tools.autogen-dashboard]
[metabase-enterprise.metabot-v3.agent.tools.charts :as tools.charts]
[metabase-enterprise.metabot-v3.agent.tools.clarification :as tools.clarification]
[metabase-enterprise.metabot-v3.agent.tools.construct :as tools.construct]
[metabase-enterprise.metabot-v3.agent.tools.document :as tools.document]
[metabase-enterprise.metabot-v3.agent.tools.metadata :as tools.metadata]
[metabase-enterprise.metabot-v3.agent.tools.navigation :as tools.navigation]
[metabase-enterprise.metabot-v3.agent.tools.resources :as tools.resources]
[metabase-enterprise.metabot-v3.agent.tools.search :as tools.search]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.agent.tools.snippets :as tools.snippets]
[metabase-enterprise.metabot-v3.agent.tools.sql :as tools.sql]
[metabase-enterprise.metabot-v3.agent.tools.subscriptions :as tools.subscriptions]
[metabase-enterprise.metabot-v3.agent.tools.todo :as tools.todo]
[metabase-enterprise.metabot-v3.agent.tools.transforms :as tools.transforms]
[potemkin :as p]))
(set! *warn-on-reflection* true)
(p/import-vars
[tools.search search-tool
sql-search-tool
nlq-search-tool
transform-search-tool]
[tools.construct
construct-notebook-query-tool]
[tools.document
document-schema-collect-tool
document-construct-sql-chart-tool
document-construct-model-chart-tool]
[tools.metadata
list-available-data-sources-tool
list-available-fields-tool
get-field-values-tool]
[tools.transforms
get-transform-details-tool
get-transform-python-library-details-tool
write-transform-sql-tool
write-transform-python-tool]
[tools.sql
create-sql-query-tool
create-sql-query-code-edit-tool
edit-sql-query-tool
edit-sql-query-code-edit-tool
replace-sql-query-tool
replace-sql-query-code-edit-tool]
[tools.charts
create-chart-tool
edit-chart-tool]
[tools.resources
read-resource-tool]
[tools.todo
todo-write-tool
todo-read-tool]
[tools.navigation
navigate-user-tool]
[tools.snippets
list-snippets-tool
get-snippet-details-tool]
[tools.clarification
ask-for-sql-clarification-tool]
[tools.autogen-dashboard
create-autogenerated-dashboard-tool]
[tools.subscriptions
create-dashboard-subscription-tool])
(def ^:dynamic ^:private *memory-atom* nil)
(def ^:private state-dependent-tools
"Set of tool names that require access to agent state."
#{"create_chart" "edit_chart" "todo_write" "todo_read" "navigate_user"
"write_transform_sql" "write_transform_python" "create_autogenerated_dashboard"
"create_sql_query" "edit_sql_query" "replace_sql_query"
"document_schema_collect" "document_construct_sql_chart" "document_construct_model_chart"})
(defn wrap-tools-with-state
"Wrap state-dependent tools with access to the memory atom.
Tools in `state-dependent-tools` will have access to the memory atom
via the dynamic `*memory-atom*` binding.
Returns a new tools map where state-dependent tools are wrapped.
Wrapped tools are returned as maps with :doc, :schema, :prompt, and :fn keys
to satisfy the Claude API schema validation. Tool-specific instructions are
loaded from `resources/metabot/prompts/tools/` by `extract-tool-instructions`
in `prompts.clj`, keyed by `:prompt` metadata or `<tool-name>.md` by default."
[tools memory-atom]
(reduce-kv
(fn [acc tool-name tool-var]
(if (contains? state-dependent-tools tool-name)
(let [{:keys [doc schema prompt decode]} (meta tool-var)
wrapped-fn (fn [args]
(binding [shared/*memory-atom* memory-atom
*memory-atom* memory-atom]
(tool-var args)))]
(assoc acc tool-name (cond-> {:doc doc
:schema schema
:prompt prompt
:fn wrapped-fn}
decode (assoc :decode decode))))
(assoc acc tool-name tool-var)))
{}
tools))

View File

@@ -0,0 +1,71 @@
(ns metabase-enterprise.metabot-v3.agent.tools.analytics
"Analytics tool wrappers for outlier detection."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.find-outliers :as outlier-tools]
[metabase-enterprise.metabot-v3.util :as metabot.u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- resolve-query-from-memory
"Resolve a query-id from agent memory into a dataset_query."
[query-id]
(when-let [query (get (shared/current-queries-state) query-id)]
query))
(defn- format-outliers-output
"Format outlier results as XML for the agent."
[outliers]
(if (empty? outliers)
"<result>No outliers detected in the data.</result>"
(metabot.u/xml
[:outliers {:count (count outliers)}
(for [{:keys [dimension value]} outliers]
[:outlier {:dimension (str dimension) :value (str value)}])])))
(mu/defn ^{:tool-name "find_outliers"}
find-outliers-tool
"Find outliers for a metric, or the numeric column of a query or report result.
Use this if the user explicitly asks for outliers, unusual mins/maxes,
whether values are within a normal range, or records that stand out in results."
[{:keys [data_source]}
:- [:map {:closed true}
[:data_source [:map
[:query_id {:optional true} [:maybe :string]]
[:result_field_id {:optional true} [:maybe :string]]
[:report_id {:optional true} [:maybe :int]]
[:metric_id {:optional true} [:maybe :int]]
[:table_id {:optional true} [:maybe :string]]]]]]
(try
(let [;; If query_id is provided, resolve it from memory to an actual query
data-source (if-let [query-id (:query_id data_source)]
(if-let [query (resolve-query-from-memory query-id)]
(-> data_source
(assoc :query query)
(dissoc :query_id))
(throw (ex-info (str "Query not found in memory: " query-id)
{:agent-error? true :query-id query-id})))
data_source)
;; Normalize keys for the backend tool
data-source (cond-> data-source
(:report_id data-source) (-> (assoc :report-id (:report_id data-source))
(dissoc :report_id))
(:metric_id data-source) (-> (assoc :metric-id (:metric_id data-source))
(dissoc :metric_id))
(:table_id data-source) (-> (assoc :table-id (:table_id data-source))
(dissoc :table_id))
(:result_field_id data-source) (-> (assoc :result-field-id (:result_field_id data-source))
(dissoc :result_field_id)))
result (outlier-tools/find-outliers {:data-source data-source})
outliers (:structured-output result)]
(if outliers
{:output (format-outliers-output outliers)
:structured-output outliers}
result))
(catch Exception e
(log/error e "Error finding outliers")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to find outliers: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,49 @@
(ns metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard
"Autogenerated dashboard tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-autogenerated-dashboard :as autogen-dashboard-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "create_autogenerated_dashboard"
:capabilities #{:automagic-dashboards}}
create-autogenerated-dashboard-tool
"Create an automatically generated dashboard using Metabase's X-ray feature.
X-ray builds complete dashboards with multiple visualizations that showcase
different aspects of your data - distributions, breakdowns by categories,
trends over time, and relationships between fields.
Specify one source type:
- table_id: Create dashboard for a table
- model_id: Create dashboard for a model
- metric_id: Create dashboard for a metric
- query_id: Create dashboard for query results from this conversation
- report_id: Create dashboard for an existing report/question"
[{:keys [source]}
:- [:map {:closed true}
[:source [:map
[:table_id {:optional true} [:maybe :int]]
[:model_id {:optional true} [:maybe :int]]
[:metric_id {:optional true} [:maybe :int]]
[:query_id {:optional true} [:maybe :string]]
[:report_id {:optional true} [:maybe :int]]]]]]
(try
(let [result (autogen-dashboard-tools/create-autogenerated-dashboard
{:source source
:memory-atom shared/*memory-atom*})
structured (:structured-output result)
reactions (:reactions result)]
(cond-> {:output (:message structured)
:structured-output structured
:final-response? (:final-response? result)
:instructions (:instructions result)}
(seq reactions) (assoc :reactions reactions)))
(catch Exception e
(log/error e "Error creating autogenerated dashboard")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create autogenerated dashboard: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,72 @@
(ns metabase-enterprise.metabot-v3.agent.tools.charts
"Chart tool wrappers."
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
[metabase-enterprise.metabot-v3.tools.edit-chart :as edit-chart-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-chart-output
[{:keys [chart-id] :as structured}]
(let [chart-xml (llm-rep/chart->xml structured)]
(str "<result>\n" chart-xml "\n</result>\n"
"<instructions>\n" (instructions/chart-created-instructions chart-id) "\n</instructions>")))
(mu/defn ^{:tool-name "create_chart"} create-chart-tool
"Create a chart from a query.
Provide a query_id in data_source and a chart_type in viz_settings."
[{:keys [data_source viz_settings]}
:- [:map {:closed true}
[:data_source [:map {:closed true}
[:query_id :string]]]
[:viz_settings [:map {:closed true}
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
(try
(let [result (create-chart-tools/create-chart
{:query-id (get data_source :query_id)
:chart-type (keyword (get viz_settings :chart_type))
:queries-state (shared/current-queries-state)})
reactions (:reactions result)
structured (assoc (dissoc result :reactions) :result-type :chart)]
(-> {:output (format-chart-output structured)
:structured-output structured}
(m/assoc-some :reactions (not-empty reactions))))
(catch Exception e
(log/error e "Error creating chart")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create chart: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "edit_chart"} edit-chart-tool
"Edit an existing chart's visualization type.
Provide a new chart_type in new_viz_settings."
[{:keys [chart_id new_viz_settings]}
:- [:map {:closed true}
[:chart_id :string]
[:new_viz_settings [:map {:closed true}
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
(try
(let [result (edit-chart-tools/edit-chart
{:chart-id chart_id
:new-chart-type (keyword (get new_viz_settings :chart_type))
:charts-state (shared/current-charts-state)})
structured (assoc result :result-type :chart)]
{:output (format-chart-output structured)
:structured-output structured})
(catch Exception e
(log/error e "Error editing chart")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to edit chart: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,30 @@
(ns metabase-enterprise.metabot-v3.agent.tools.clarification
"Clarification tool wrappers."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.ask-clarification :as ask-clarification-tools]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-clarification-output
[{:keys [question options]}]
(str question
(when (seq options)
(str "\n\nOptions:\n" (str/join "\n" (map #(str "- " %) options))))))
(mu/defn ^{:tool-name "ask_for_sql_clarification"} ask-for-sql-clarification-tool
"Ask the user for clarification about their SQL query request.
Use this tool when you need more information from the user to properly
complete their SQL request. The agent will stop and wait for the user's response."
[{:keys [question options]}
:- [:map {:closed true}
[:question :string]
[:options {:optional true} [:maybe [:sequential :string]]]]]
(try
(let [result (ask-clarification-tools/ask-for-sql-clarification {:question question
:options options})]
(assoc result :output (format-clarification-output (:structured-output result))))
(catch Exception e
{:output (str "Failed to ask clarification: " (or (ex-message e) "Unknown error"))})))

View File

@@ -0,0 +1,568 @@
(ns metabase-enterprise.metabot-v3.agent.tools.construct
"Notebook query construction tool wrappers."
(:require
[clojure.walk :as walk]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
[metabase-enterprise.metabot-v3.tools.filters :as filter-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.util :as metabot-u]
[metabase.lib.core :as lib]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- normalize-ai-args
"Normalize nested tool arguments to kebab-case keys and keyword enums."
[value]
(let [normalized (when value
(metabot-u/recursive-update-keys value metabot-u/safe->kebab-case-en))
enum-keys #{:operation :bucket :function :sort-order :direction :field-granularity :filter-type :operator}
normalize-enum (fn [v]
(cond
(keyword? v) (-> v name metabot-u/safe->kebab-case-en keyword)
(string? v) (let [normalized-v (metabot-u/safe->kebab-case-en v)]
(case normalized-v
"ascending" :asc
"descending" :desc
(keyword normalized-v)))
:else v))]
(walk/postwalk
(fn [x]
(if (map? x)
(reduce-kv (fn [m k v]
(assoc m k (if (enum-keys k) (normalize-enum v) v)))
{}
x)
x))
normalized)))
;; Query construction is handled by construct_notebook_query for parity with ai-service.
(def ^:private construct-field-schema
[:map {:closed true}
[:field_id :string]
[:field_granularity {:optional true} [:maybe :string]]
[:bucket {:optional true} [:maybe :string]]])
(def ^:private construct-field-aggregation-schema
[:map {:closed true}
[:field_id {:optional true} :string]
;; expression_ref allows aggregating on a named expression (calculated column)
[:expression_ref {:optional true} :string]
[:function [:enum "count" "count-distinct" "distinct" "sum" "min" "max" "avg"
;; Advanced aggregations
"median" "stddev" "var" "percentile"
"cum-sum" "cum-count" "share"]]
[:sort_order {:optional true} [:maybe [:enum "ascending" "descending"]]]
[:bucket {:optional true} [:maybe :string]]
;; For percentile function (0-1 range, e.g., 0.95 for 95th percentile)
[:percentile_value {:optional true} [:maybe number?]]])
(def ^:private construct-measure-aggregation-schema
[:map {:closed true}
[:measure_id :int]
[:sort_order {:optional true} [:maybe :string]]])
(def ^:private construct-order-by-schema
[:map {:closed true}
[:field construct-field-schema]
[:direction :string]])
;; Canonical operation values per filter_type.
;; These appear as enum constraints in the JSON Schema sent to the LLM.
(def ^:private multi-value-operations
["equals" "not-equals"
"string-starts-with" "string-ends-with"
"string-contains" "string-not-contains"
;; aliases the LLM may send from system-instruction shorthand
"contains" "not-contains" "does-not-contain"
"starts-with" "ends-with"])
(def ^:private single-value-operations
["greater-than" "greater-than-or-equal"
"less-than" "less-than-or-equal"
;; LLM may use equals/not-equals with a single value instead of multi_value
"equals" "not-equals"])
(def ^:private no-value-operations
["is-null" "is-not-null"
"string-is-empty" "string-is-not-empty"
"is-true" "is-false"
;; aliases
"is-empty" "is-not-empty"])
(def ^:private construct-multi-value-filter-schema
[:map {:closed true
:decode/tool filter-tools/decode-temporal-filter}
[:filter_type [:enum "multi_value"]]
[:field_id :string]
[:operation (into [:enum] multi-value-operations)]
[:bucket {:optional true} [:maybe :string]]
[:value {:optional true} [:maybe :any]]
[:values {:optional true} [:maybe [:sequential :any]]]])
(def ^:private construct-single-value-filter-schema
[:map {:closed true
:decode/tool filter-tools/decode-temporal-filter}
[:filter_type [:enum "single_value"]]
[:field_id :string]
[:operation (into [:enum] single-value-operations)]
[:bucket {:optional true} [:maybe :string]]
[:value {:optional true} [:maybe :any]]
[:values {:optional true} [:maybe [:sequential :any]]]])
(def ^:private construct-no-value-filter-schema
[:map {:closed true}
[:filter_type [:enum "no_value"]]
[:field_id :string]
[:operation (into [:enum] no-value-operations)]])
(def ^:private construct-segment-filter-schema
[:map {:closed true}
[:filter_type [:enum "segment"]]
[:segment_id :int]])
(def ^:private construct-between-filter-schema
[:map {:closed true
:decode/tool filter-tools/decode-temporal-filter}
[:filter_type [:enum "between"]]
[:field_id :string]
[:lower_value :any]
[:upper_value :any]
[:bucket {:optional true} [:maybe :string]]])
;; Non-compound filter schema (used inside compound filters to avoid infinite recursion)
(def ^:private construct-leaf-filter-schema
"Filter schema without compound - used as children of compound filters."
[:or
construct-multi-value-filter-schema
construct-single-value-filter-schema
construct-no-value-filter-schema
construct-segment-filter-schema
construct-between-filter-schema])
;; Recursive filter schema so compound filters can be nested.
(def ^:private construct-filter-registry
{::construct-filter
[:or
construct-leaf-filter-schema
[:map {:closed true}
[:filter_type [:enum "compound"]]
[:operator [:enum "and" "or"]]
[:filters [:sequential {:min 1} [:ref ::construct-filter]]]]]})
(def ^:private construct-filter-schema
[:schema {:registry construct-filter-registry}
[:ref ::construct-filter]])
;; Simple filter schema for conditional aggregation conditions.
;; Conditions don't need compound (AND/OR) filters - those are for top-level filters only.
(def ^:private construct-simple-condition-filter-schema
"Non-compound filter schema for use in conditional aggregation conditions."
construct-leaf-filter-schema)
(def ^:private construct-conditional-aggregation-schema
"Schema for conditional aggregations like count-where, sum-where, distinct-where."
[:map {:closed true}
[:field_id {:optional true} :string]
[:function [:enum "count-where" "sum-where" "distinct-where"]]
[:condition construct-simple-condition-filter-schema]
[:sort_order {:optional true} [:maybe [:enum "ascending" "descending"]]]
[:bucket {:optional true} [:maybe :string]]])
(def ^:private construct-metric-schema
[:or
construct-field-aggregation-schema
construct-measure-aggregation-schema
construct-conditional-aggregation-schema])
(def ^:private construct-source-metric-schema
[:map {:closed true}
[:metric_id :int]])
(def ^:private construct-source-model-schema
[:map {:closed true}
[:model_id :int]])
(def ^:private construct-source-table-schema
[:map {:closed true}
[:table_id :int]])
(def ^:private construct-visualization-schema
[:map {:closed true}
[:chart_type :string]])
;;; Expression schemas for calculated columns
;;
;; Expressions support nested sub-expressions as arguments, e.g.:
;; {:operation "divide"
;; :arguments [{:operation "subtract" :arguments [...]} ; nested expression
;; {:field_id "t5-3"}]} ; field reference
(def ^:private expression-operation-enum
"Valid operations for expressions."
[:enum
;; Math operations (binary - take multiple args)
"add" "subtract" "multiply" "divide"
;; Math operations (unary)
"abs" "round" "ceil" "floor" "sqrt" "log" "exp" "power"
;; String operations
"concat" "upper" "lower" "trim" "length" "substring"
;; Date extraction
"get-year" "get-month" "get-day" "get-hour" "get-minute" "get-second"
"get-quarter" "get-day-of-week"
;; Date arithmetic
"datetime-add" "datetime-subtract"
;; Coalesce
"coalesce"])
;; Inline sub-expression (used as an argument, doesn't have a name)
(def ^:private construct-inline-expression-schema
"Schema for an inline/nested expression used as an argument to another expression."
[:map {:closed true}
[:operation expression-operation-enum]
[:arguments [:sequential [:ref ::expression-argument]]]
;; Optional fields for specific operations
[:unit {:optional true} [:maybe [:enum "year" "quarter" "month" "week" "day"
"hour" "minute" "second"]]]
[:start {:optional true} [:maybe :int]]
[:end {:optional true} [:maybe :int]]
[:exponent {:optional true} [:maybe number?]]])
;; Use Malli registry for recursive schema
(def ^:private expression-argument-registry
{::expression-argument
[:or
[:map {:closed true} [:field_id :string]]
[:map {:closed true} [:value :any]]
[:map {:closed true} [:expression_ref :string]]
construct-inline-expression-schema]})
(def ^:private construct-expression-schema
"Schema for defining calculated columns/expressions (top-level, must have a name)."
[:map {:closed true}
[:name :string]
[:operation expression-operation-enum]
[:arguments [:schema {:registry expression-argument-registry}
[:sequential [:ref ::expression-argument]]]]
;; For datetime-add/subtract operations
[:unit {:optional true} [:maybe [:enum "year" "quarter" "month" "week" "day"
"hour" "minute" "second"]]]
;; For substring operation
[:start {:optional true} [:maybe :int]]
[:end {:optional true} [:maybe :int]]
;; For power operation
[:exponent {:optional true} [:maybe number?]]])
;;; Post-aggregation filter schema (HAVING equivalent)
(def ^:private construct-leaf-post-filter-schema
"Schema for a single post-aggregation filter. References aggregations by their 0-based index."
[:map {:closed true}
[:aggregation_index :int]
[:operation [:enum "greater-than" "less-than" "equals" "not-equals"
"greater-than-or-equal" "less-than-or-equal"]]
[:value :any]])
(def ^:private construct-compound-post-filter-schema
"Schema for combining multiple post-aggregation filters with AND/OR."
[:map {:closed true}
[:filter_type [:enum "compound"]]
[:operator [:enum "and" "or"]]
[:filters [:sequential {:min 1} construct-leaf-post-filter-schema]]])
(def ^:private construct-post-filter-schema
"Schema for post-aggregation filtering. Can be a simple filter or compound (AND/OR)."
[:or
construct-leaf-post-filter-schema
construct-compound-post-filter-schema])
;; The LLM sometimes nests visualization inside query, so we accept it as optional
;; in each query variant and pull it out during normalization.
(def ^:private construct-query-metric-schema
[:map {:closed true}
[:query_type [:enum "metric"]]
[:source construct-source-metric-schema]
[:filters [:sequential construct-filter-schema]]
[:group_by [:sequential construct-field-schema]]
[:visualization {:optional true} construct-visualization-schema]])
(def ^:private construct-query-aggregate-schema
[:map {:closed true}
[:query_type [:enum "aggregate"]]
[:source [:or construct-source-model-schema construct-source-table-schema]]
[:expressions {:optional true} [:sequential construct-expression-schema]]
[:aggregations [:sequential construct-metric-schema]]
[:filters {:optional true} [:maybe [:sequential construct-filter-schema]]]
[:group_by [:sequential construct-field-schema]]
[:limit {:optional true} [:maybe :int]]
[:post_filters {:optional true} [:sequential construct-post-filter-schema]]
[:visualization {:optional true} construct-visualization-schema]])
(def ^:private construct-query-raw-schema
[:map {:closed true}
[:query_type [:enum "raw"]]
[:source [:or construct-source-model-schema construct-source-table-schema]]
[:expressions {:optional true} [:sequential construct-expression-schema]]
[:filters [:sequential construct-filter-schema]]
[:fields [:sequential construct-field-schema]]
[:order_by [:sequential construct-order-by-schema]]
[:limit [:maybe :int]]
[:visualization {:optional true} construct-visualization-schema]])
(def ^:private construct-query-schema
[:or construct-query-metric-schema construct-query-aggregate-schema construct-query-raw-schema])
(def ^:private construct-operation-aliases
{"contains" :string-contains
"not-contains" :string-not-contains
"does-not-contain" :string-not-contains
"starts-with" :string-starts-with
"ends-with" :string-ends-with
"is-empty" :string-is-empty
"is-not-empty" :string-is-not-empty})
(defn- normalize-construct-operation
[operation]
(let [op (cond
(keyword? operation) operation
(string? operation) (keyword operation)
:else operation)]
(get construct-operation-aliases (name op) op)))
(defn- normalize-construct-filter
[filter]
(let [normalized (normalize-ai-args filter)
filter-type (:filter-type normalized)
;; Only normalize operation if it exists (compound, segment, between don't have operations)
normalized (if (:operation normalized)
(assoc normalized :operation (normalize-construct-operation (:operation normalized)))
normalized)]
(case filter-type
:multi-value (-> normalized
(dissoc :filter-type :value)
(update :values (fn [vals]
(cond
(sequential? vals) (vec vals)
(some? (:value normalized)) [(:value normalized)]
:else []))))
:single-value (-> normalized
(dissoc :filter-type :values)
(assoc :value (or (:value normalized)
(first (:values normalized)))))
:no-value (-> normalized
(dissoc :filter-type :value :values))
:segment (-> normalized
(dissoc :filter-type :field-id :operation :bucket :value :values))
:between (-> normalized
(dissoc :filter-type)
(assoc :filter-kind :between))
:compound (-> normalized
(dissoc :filter-type)
(assoc :filter-kind :compound)
(update :filters #(mapv normalize-construct-filter %)))
(dissoc normalized :filter-type))))
(defn- normalize-construct-filters
[filters]
(mapv normalize-construct-filter filters))
(defn- normalize-post-filter
"Normalize a post-filter, handling both leaf filters and compound (AND/OR) filters."
[post-filter]
(let [normalized (normalize-ai-args post-filter)]
(if (= :compound (:filter-type normalized))
;; Compound post-filter
(-> normalized
(dissoc :filter-type)
(assoc :filter-kind :compound)
(update :filters #(mapv normalize-post-filter %)))
;; Leaf post-filter - just normalize the operation to keyword
(update normalized :operation #(cond
(keyword? %) %
(string? %) (keyword %)
:else %)))))
(defn- normalize-post-filters
"Normalize post-filters array."
[post-filters]
(when (seq post-filters)
(mapv normalize-post-filter post-filters)))
(defn- chart-type->keyword
[chart-type]
(cond
(keyword? chart-type) chart-type
(string? chart-type) (keyword chart-type)
:else chart-type))
(defn- query-type->keyword
[query-type]
(cond
(keyword? query-type) query-type
(string? query-type) (keyword query-type)
:else query-type))
(def ^:private construct-notebook-query-args-schema
"Schema for the `construct_notebook_query` tool arguments.
Filter sub-schemas carry `:decode/tool` transforms for temporal value coercion."
[:map {:closed true}
[:reasoning {:optional true} :string]
[:query construct-query-schema]
[:visualization {:optional true} construct-visualization-schema]])
(defn- structured->query-data
"Convert tool structured output to a map suitable for [[llm-rep/query->xml]].
Converts the pMBQL query to legacy MBQL, JSON-encodes it, and wraps result columns."
[{:keys [query-id query result-columns]}]
(let [legacy-query (when (and (map? query) (:lib/type query))
#_{:clj-kondo/ignore [:discouraged-var]}
(lib/->legacy-MBQL query))]
{:query-type "notebook"
:query-id query-id
:database_id (:database legacy-query)
:query-content (when legacy-query (json/encode legacy-query))
:result (when (seq result-columns)
{:result_columns result-columns})}))
(defn- structured->chart-xml
"Render the full chart XML (matching Python Visualization.llm_representation)
for the construct_notebook_query tool result."
[structured chart-id chart-type]
(llm-rep/visualization->xml
{:chart-id chart-id
:queries [(structured->query-data structured)]
:visualization_settings {:chart_type (if chart-type (name chart-type) "table")}}))
(def ^:private tool-arg-transformer
"Malli transformer that applies `:decode/tool` transforms declared on sub-schemas.
Used by [[decode-tool-args]] to coerce LLM arguments (e.g., date strings → integers
for extraction buckets) before the tool function runs."
(mtx/transformer {:name :tool}))
(defn- decode-tool-args
"Decode tool arguments through the Malli schema, applying `:decode/tool` transforms.
This is attached as `:decode` metadata on the tool var so that [[run-tool]] calls it
before invoking the tool function."
[args]
(mc/decode construct-notebook-query-args-schema args tool-arg-transformer))
(mu/defn ^{:tool-name "construct_notebook_query"
:decode decode-tool-args}
construct-notebook-query-tool
"Constructs a query using either a **single model OR a single metric OR a single table** as the data source, never more than one.
**Capabilities:**
- **Aggregations**: Basic (sum, avg, min, max, count, count-distinct) and advanced (median, stddev, var, percentile, cum-sum, cum-count, share)
- **Conditional aggregations**: count-where, sum-where, distinct-where with filter conditions
- **Filters**: AND/OR compound filters, between ranges, segments, and standard comparisons
- **Expressions**: Calculated columns with math, string, and date operations
- **Post-aggregation filters**: HAVING-equivalent filtering on aggregated results
- **Grouping/breakout**: With temporal bucketing support
**Limitations:**
- No joins, unions, subqueries, or cross-model/metric queries
- No window functions (except cumulative aggregations), cohort analysis
- All fields used must exist in the selected model, metric, or table
**Usage:**
- Always use an existing business metric (`metric_id`) when it matches the intended aggregate—never rebuild a metric from scratch.
- For multiple metrics: make separate tool calls, one per metric
- For custom calculations or detailed record exploration on a specific model use the model (`model_id`). The query will operate on this single model. Only fields belonging to THIS model can be used.
- For raw table data exploration use the table (`table_id`). The query will operate on this single table. Only fields belonging to THIS table can be used.
- For categorical filters, only use values validated as present in the data.
- Use `expressions` to create calculated columns (e.g., profit margin = revenue / cost)
- Use `post_filters` to filter on aggregated results (e.g., categories with total > $10,000)
**Important Note about Metrics:**
When using metrics, be aware that they often contain built-in filters and business logic that don't need to be explicitly added. For example, metrics named with states like \"active users\", \"converted customers\", or \"paid subscribers\" already include the relevant filtering conditions (activity, conversion, payment status) in their definitions and do not require additional filters for these criteria.
Never use this tool if the user's request explicitly asks for a SQL query or requires inter-row logic (comparing different rows), or joins. Use SQL tools instead if available.
If the request is out of scope, do not use this tool — suggest alternatives or refer to a more advanced context if available"
[{:keys [_reasoning query visualization]} :- construct-notebook-query-args-schema]
(try
(let [;; LLM sometimes nests visualization inside query — pull it out
effective-viz (or visualization (:visualization query))
normalized-query (normalize-ai-args (dissoc query :visualization))
normalized-visualization (normalize-ai-args effective-viz)
query-type (query-type->keyword (:query-type normalized-query))
chart-type (or (chart-type->keyword (:chart-type normalized-visualization))
:table)
_ (log/debug "construct_notebook_query request"
{:query-type query-type
:chart-type chart-type})
query-result (case query-type
:metric
(filter-tools/query-metric
{:metric-id (get-in normalized-query [:source :metric-id])
:filters (normalize-construct-filters (:filters normalized-query))
:group-by (normalize-ai-args (:group-by normalized-query))})
:aggregate
(filter-tools/query-datasource
{:model-id (get-in normalized-query [:source :model-id])
:table-id (get-in normalized-query [:source :table-id])
:expressions (normalize-ai-args (:expressions normalized-query))
:aggregations (normalize-ai-args (:aggregations normalized-query))
:filters (normalize-construct-filters (:filters normalized-query))
:group-by (normalize-ai-args (:group-by normalized-query))
:limit (:limit normalized-query)
:post-filters (normalize-post-filters (:post-filters normalized-query))})
:raw
(filter-tools/query-datasource
{:model-id (get-in normalized-query [:source :model-id])
:table-id (get-in normalized-query [:source :table-id])
:expressions (normalize-ai-args (:expressions normalized-query))
:fields (normalize-ai-args (:fields normalized-query))
:filters (normalize-construct-filters (:filters normalized-query))
:order-by (normalize-ai-args (:order-by normalized-query))
:limit (:limit normalized-query)})
{:output (str "Unsupported query_type: " query-type)})
structured (or (:structured-output query-result) (:structured_output query-result))]
(if (and structured (:query-id structured) (:query structured))
(let [chart-result (create-chart-tools/create-chart
{:query-id (:query-id structured)
:chart-type chart-type
:queries-state {(:query-id structured) (:query structured)}})
navigate-url (get-in chart-result [:reactions 0 :url])
full-structured (assoc structured
:result-type :query
:chart-id (:chart-id chart-result)
:chart-type (:chart-type chart-result)
:chart-link (:chart-link chart-result)
:chart-content (:chart-content chart-result))
instruction-text
(let [link (te/link "Chart" "metabase://chart/" (:chart-id chart-result))]
(te/lines
"Your query and chart have been created successfully."
""
"Next steps to present the chart to the user:"
(str "- Always provide a direct link using: `" link "` where Chart is a meaningful link text")
"- If creating multiple charts, present all chart links"))
chart-xml (structured->chart-xml structured (:chart-id chart-result) chart-type)]
{:output (str "<result>\n" chart-xml "\n</result>\n"
"<instructions>\n" instruction-text "\n</instructions>")
:data-parts (when navigate-url
[(streaming/navigate-to-part navigate-url)])
:structured-output full-structured
:instructions instruction-text})
;; query-result may already have :output (error) or only :structured-output
(if-let [s (or (:structured-output query-result) (:structured_output query-result))]
(let [query-xml (llm-rep/query->xml (structured->query-data s))
instruction-text (instructions/query-created-instructions-for (:query-id s))]
(assoc query-result
:output (str "<result>\n" query-xml "\n</result>\n"
"<instructions>\n" instruction-text "\n</instructions>")))
query-result)))
(catch Exception e
(log/error e "Failed to construct notebook query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to construct notebook query: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,166 @@
(ns metabase-enterprise.metabot-v3.agent.tools.document
"Document-generation specific tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.construct :as construct-tools]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.warehouses.core :as warehouses]))
(set! *warn-on-reflection* true)
(def ^:private chart-type-enum
[:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"])
(defn- parse-reference
[[k v]]
(let [reference (name k)]
(when-let [[_ reference-type reference-id] (re-matches #"([^:]+):(.+)" reference)]
{:reference reference
:type reference-type
:id reference-id
:name v})))
(defn- parse-int
[value]
(cond
(int? value) value
(and (string? value) (re-matches #"\d+" value)) (Integer/parseInt value)
:else nil))
(defn- context-references
[]
(or (get (shared/current-context) :references) {}))
(defn- referenced-database-ids
[references]
(into []
(comp (map parse-reference)
(filter #(= "database" (:type %)))
(map (comp parse-int :id))
(remove nil?))
references))
(mu/defn ^{:tool-name "document_schema_collect"}
document-schema-collect-tool
"Collects the schema of a database in order to construct a SQL query.
**Usage:**
- Use this tool when the user is explicitly requesting to use SQL
- Use this tool when an existing model or metric does not answer the user's question.
- Do NOT use this tool if there is a model or metric that can answer the user's question
unless the user is requesting SQL."
[_args :- [:maybe [:map {:closed true}]]]
(try
(let [refs (context-references)
database-ids (referenced-database-ids refs)]
(cond
(empty? database-ids)
{:output "You must `@` mention a database to use when not querying an existing model"}
(< 1 (count database-ids))
{:output "You can only `@` mention one database when generating SQL"}
:else
(let [database-id (first database-ids)
db (warehouses/get-database database-id)
schema-ddl (table-utils/schema-full database-id)
output (str "<result>\n"
"The database the user selected has this schema:\n"
"<database_schema>\n"
schema-ddl
"\n</database_schema>\n"
"</result>\n"
"<instructions>\n"
"NEXT: construct a SQL query based on the user's instructions and this schema.\n"
"THEN: call `document_construct_sql_chart` with SQL and chart settings.\n"
"SQL engine: " (:engine db) ".\n"
"</instructions>")]
{:output output
:structured-output {:database_id database-id
:sql_engine (:engine db)}})))
(catch Exception e
(log/error e "Error collecting document schema")
{:output (str "Failed to collect schema: " (or (ex-message e) "Unknown error"))})))
(mu/defn ^{:tool-name "document_construct_sql_chart"}
document-construct-sql-chart-tool
"Construct SQL-backed chart draft payload for document insertion."
[{:keys [database_id name description analysis approach sql viz_settings]}
:- [:map {:closed true}
[:database_id :int]
[:name :string]
[:description :string]
[:analysis :string]
[:approach :string]
[:sql :string]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]]]
(try
(let [result (create-sql-query-tools/create-sql-query {:database-id database_id
:sql sql})
chart-type (get viz_settings :chart_type)
query-id (:query-id result)
query (:query result)
structured {:tool "document_construct_sql_chart"
:name name
:description description
:analysis analysis
:approach approach
:dataset_query query
:display chart-type
:chart_type chart-type
:query_id query-id
:query query
:result-type :chart-draft}]
{:output "Draft chart payload generated from SQL query."
:structured-output structured
:final-response? true})
(catch Exception e
(log/error e "Error constructing SQL chart draft")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to construct SQL chart draft: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "document_construct_model_chart"}
document-construct-model-chart-tool
"Construct notebook/model-backed chart draft payload for document insertion."
[{:keys [name description query viz_settings]}
:- [:map {:closed true}
[:name :string]
[:description :string]
[:query :map]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]]]
(try
(let [chart-type (get viz_settings :chart_type)
result (construct-tools/construct-notebook-query-tool
{:query query
:visualization {:chart_type chart-type}})
structured (or (:structured-output result) (:structured_output result))
query-id (:query-id structured)
dataset-query (:query structured)]
(if (map? dataset-query)
{:output "Draft chart payload generated from model/notebook query."
:structured-output {:tool "document_construct_model_chart"
:name name
:description description
:dataset_query dataset-query
:display chart-type
:chart_type chart-type
:query_id query-id
:query dataset-query
:result-type :chart-draft}
:final-response? true}
;; Preserve tool error messaging from construct_notebook_query path.
(or result
{:output "Failed to construct model chart draft."})))
(catch Exception e
(log/error e "Error constructing model chart draft")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to construct model chart draft: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,80 @@
(ns metabase-enterprise.metabot-v3.agent.tools.metadata
"Metadata tool wrappers."
(:require
[clojure.string :as str]
[medley.core :as m]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details-tools]
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats-tools]
[metabase-enterprise.metabot-v3.tools.get-metadata :as metadata-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-with-instructions
[data instruction-text]
(str "<result>\n" data "\n</result>\n"
"<instructions>\n" instruction-text "\n</instructions>"))
(defn- format-answer-sources-output
[{:keys [metrics models]}]
(let [content (str (when (seq metrics)
(str "<metrics>\n"
(str/join "\n" (map llm-rep/metric->xml metrics))
"\n</metrics>\n"))
(when (seq models)
(str "<metabase-models>\n"
(str/join "\n" (map llm-rep/model->xml models))
"\n</metabase-models>")))]
(format-with-instructions content instructions/answer-sources-instructions)))
(defn- format-metadata-output
[structured]
(llm-rep/get-metadata-result->xml structured))
(defn- format-field-metadata-output
;; NOTE: keep in sync with read_resource.clj/format-content :field-metadata branch
[{:keys [field_id value_metadata]}]
(format-with-instructions
(llm-rep/field-metadata->xml {:field_id field_id :value_metadata value_metadata})
instructions/field-metadata-instructions))
(defn- add-output
"Add :output to a tool result that has :structured-output, using the given format-fn."
[result format-fn]
(m/assoc-some result :output (some-> result :structured-output format-fn)))
(mu/defn ^{:tool-name "list_available_data_sources"} list-available-data-sources-tool
"List all data sources (metrics and models) available to the metabot instance."
[_args :- [:maybe [:map {:closed true}]]]
(add-output
(entity-details-tools/answer-sources {:metabot-id metabot-v3.config/embedded-metabot-id
:with-field-values? false})
format-answer-sources-output))
(mu/defn ^{:tool-name "list_available_fields"} list-available-fields-tool
"Retrieve metadata for tables, models, and metrics."
[{:keys [table_ids model_ids metric_ids]} :- [:map {:closed true}
[:table_ids [:sequential :int]]
[:model_ids [:sequential :int]]
[:metric_ids [:sequential :int]]]]
(add-output
(metadata-tools/get-metadata {:table-ids table_ids
:model-ids model_ids
:metric-ids metric_ids})
format-metadata-output))
(mu/defn ^{:tool-name "get_field_values"} get-field-values-tool
"Return metadata for a given field of a given data source."
[{:keys [data_source source_id field_id]} :- [:map {:closed true}
[:data_source [:enum "table" "model" "metric"]]
[:source_id :int]
[:field_id :string]]]
(add-output
(field-stats-tools/field-values {:entity-type data_source
:entity-id source_id
:field-id field_id
:limit nil})
format-field-metadata-output))

View File

@@ -0,0 +1,47 @@
(ns metabase-enterprise.metabot-v3.agent.tools.navigation
"Navigation tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.navigate :as navigate-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "navigate_user"
:capabilities #{:frontend-navigate-user-v1}}
navigate-user-tool
"Navigate the user to a specific page or entity in Metabase.
Use this tool to direct users to:
- Pages: notebook_editor, metrics_browser, model_browser, database_browser, sql_editor
- Entities: table, model, question, metric, dashboard
- Query results or charts from the current conversation"
[{:keys [destination]}
:- [:map {:closed true}
[:destination [:or
[:map {:closed true}
[:page [:enum "notebook_editor" "metrics_browser" "model_browser" "database_browser"]]]
[:map {:closed true}
[:page [:enum "sql_editor"]]
[:database_id :int]]
[:map {:closed true}
[:entity_type [:enum "table" "model" "question" "metric" "dashboard"]]
[:entity_id :int]]
[:map {:closed true}
[:query_id :string]]
[:map {:closed true}
[:chart_id :string]]]]]]
(try
(let [result (navigate-tools/navigate {:destination destination
:memory-atom shared/*memory-atom*})
structured (:structured-output result)
reactions (:reactions result)]
(cond-> {:output (:message structured)
:structured-output structured}
(seq reactions) (assoc :reactions reactions)))
(catch Exception e
(log/error e "Error navigating")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to navigate: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,26 @@
(ns metabase-enterprise.metabot-v3.agent.tools.resources
"Resource reading tool wrappers."
(:require
[metabase-enterprise.metabot-v3.tools.read-resource :as read-resource-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "read_resource"} read-resource-tool
"Read detailed information about Metabase resources via URI patterns.
Supports fetching multiple resources in parallel using metabase:// URIs:
- metabase://table/{id}/fields - Get table structure with fields
- metabase://model/{id}/fields/{field_id} - Get specific field details
- metabase://metric/{id}/dimensions - Get metric dimensions
- metabase://transform/{id} - Get transform details
- metabase://dashboard/{id} - Get dashboard details"
[{:keys [uris]}
:- [:map {:closed true}
[:uris [:sequential [:string {:description "Metabase resource URIs to fetch"}]]]]]
(try
(read-resource-tools/read-resource {:uris uris})
(catch Exception e
(log/error e "Error in read_resource tool")
{:output (str "Failed to read resources: " (or (ex-message e) "Unknown error"))})))

View File

@@ -0,0 +1,126 @@
(ns metabase-enterprise.metabot-v3.agent.tools.search
"Search tool wrappers for Metabot v3."
(:require
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.tools.search :as search-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-search-output
"Format search results as an LLM-ready string."
[results]
(let [results-xml (llm-rep/search-results->xml results)]
(te/lines
"<result>"
results-xml
""
(str "Total results: " (count results))
"</result>"
"<instructions>"
instructions/search-result-instructions "</instructions>")))
(defn- invalid-entity-types
[entity-types allowed]
(when (seq entity-types)
(seq (remove allowed entity-types))))
(mu/defn ^{:tool-name "search"} search-tool
"Search for tables, models, metrics, dashboards, and saved questions."
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "metric" "dashboard" "question"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "metric" "dashboard" "question"})]
{:output (str "Invalid entity_types for search: " (pr-str (vec invalid))
". Allowed types: table, model, metric, dashboard, question.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "sql_search.md"} sql-search-tool
"Search for SQL-queryable data sources (tables and models) within a database."
[{:keys [semantic_queries keyword_queries entity_types database_id]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:database_id :int]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model"})]
{:output (str "Invalid entity_types for SQL search: " (pr-str (vec invalid))
". Allowed types: table, model.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:database-id database_id
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in SQL search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "nql_search.md"} nlq-search-tool
"Search for NLQ-queryable data sources (models, metrics, tables)."
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "model" "metric" "table"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"model" "metric" "table"})]
{:output (str "Invalid entity_types for NLQ search: " (pr-str (vec invalid))
". Allowed types: model, metric, table.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:profile-id "nlq"
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in NLQ search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "transform_search"} transform-search-tool
"Search for transforms, tables, and models."
[{:keys [semantic_queries keyword_queries entity_types search_native_query]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:search_native_query {:optional true} [:maybe :boolean]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "transform"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "transform"})]
{:output (str "Invalid entity_types for transform search: " (pr-str (vec invalid))
". Allowed types: table, model, transform.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:search-native-query search_native_query
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in transform search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,40 @@
(ns metabase-enterprise.metabot-v3.agent.tools.shared
"Shared tool helpers and state accessors."
(:require
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:dynamic *memory-atom*
"Dynamic memory atom bound for tools that need access to agent state."
nil)
(defn current-memory
"Returns the current agent memory map, or nil if not in an agent context."
[]
(when *memory-atom*
@*memory-atom*))
(defn current-queries-state
"Returns the current queries state map from agent memory."
[]
(get-in (current-memory) [:state :queries] {}))
(defn current-charts-state
"Returns the current charts state map from agent memory."
[]
(get-in (current-memory) [:state :charts] {}))
(defn current-context
"Returns the current agent context from memory."
[]
(get (current-memory) :context))
(defn with-memory
"Helper for debugging memory-bound tools when needed."
[f]
(if *memory-atom*
(f)
(do
(log/warn "Tool called without bound memory atom")
(f))))

View File

@@ -0,0 +1,55 @@
(ns metabase-enterprise.metabot-v3.agent.tools.snippets
"Snippet tool wrappers."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.snippets :as snippet-tools]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-snippet-list-output
[snippets]
(if (seq snippets)
(str "<snippets>\n"
(str/join "\n" (map (fn [{:keys [id name description]}]
(str "<snippet id=\"" id "\" name=\"" name "\">"
(when description description)
"</snippet>"))
snippets))
"\n</snippets>")
"No snippets available."))
(defn- format-snippet-details-output
[{:keys [id name description content]}]
(str "<snippet id=\"" id "\" name=\"" name "\">\n"
(when description (str "<description>" description "</description>\n"))
(when content (str "<content>\n" content "\n</content>\n"))
"</snippet>"))
(defn- add-output
"Add :output to a tool result. Handles both :structured_output and :structured-output."
[result format-fn]
(if-let [structured (or (:structured_output result) (:structured-output result))]
(assoc result :output (format-fn structured))
result))
(mu/defn ^{:tool-name "list_snippets"
:capabilities #{:feature-snippets}} list-snippets-tool
"List all SQL snippets available in the Metabase instance.
Use this tool before editing or creating SQL transforms to understand what
snippets are available. If any snippets may be relevant to the user's request,
fetch their content using the get_snippet_details tool."
[_args :- [:maybe [:map {:closed true}]]]
(add-output (snippet-tools/get-snippets {}) format-snippet-list-output))
(mu/defn ^{:tool-name "get_snippet_details"
:capabilities #{:feature-snippets}} get-snippet-details-tool
"Get the full details of a SQL snippet including its content.
Use this tool to retrieve the actual SQL content of a snippet after identifying
it with list_snippets."
[{:keys [snippet_id]}
:- [:map {:closed true}
[:snippet_id :int]]]
(add-output (snippet-tools/get-snippet-details {:snippet-id snippet_id}) format-snippet-details-output))

View File

@@ -0,0 +1,222 @@
(ns metabase-enterprise.metabot-v3.agent.tools.sql
"SQL tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
[metabase-enterprise.metabot-v3.tools.edit-sql-query :as edit-sql-query-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.tools.replace-sql-query :as replace-sql-query-tools]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- normalize-context-type
[type-val]
(cond
(keyword? type-val) (name type-val)
(string? type-val) (u/lower-case-en type-val)
:else ""))
(defn- first-code-editor-buffer-id
[]
(let [memory (shared/current-memory)]
(when memory
(let [viewing (get-in memory [:context :user_is_viewing] [])]
(some->> viewing
(filter #(= "code_editor" (normalize-context-type (:type %))))
first
:buffers
first
:id)))))
(defn- format-query-output
"Format a query result for LLM consumption.
`preamble` is optional text placed before the query XML inside <result>
(e.g. \"SQL query successfully constructed.\")."
[structured instruction-text & {:keys [preamble?]}]
(let [query-xml (llm-rep/query->xml structured)]
(te/lines
"<result>"
(when preamble?
(te/lines
"SQL query successfully constructed."
(str "New query ID: " (:query-id structured))
"The complete query is shown below:"))
query-xml
"</result>"
"<instructions>"
instruction-text
"</instructions>")))
(defn- code-edit-part
[buffer-id sql]
(streaming/code-edit-part {:buffer_id buffer-id
:mode "rewrite"
:value sql}))
(mu/defn ^{:tool-name "create_sql_query"
:capabilities #{:permission-write-sql-queries}}
create-sql-query-tool
"Create a new SQL query."
[{:keys [database_id sql_query]}
:- [:map {:closed true}
[:database_id :int]
[:sql_query :string]]]
(try
(let [result (create-sql-query-tools/create-sql-query
{:database-id database_id
:sql sql_query})
structured (assoc result :result-type :query)
query-id (:query-id result)
instr (instructions/query-created-instructions-for query-id)
results-url (streaming/query->question-url (:query result))]
{:output (format-query-output structured instr {:preamble? true})
:structured-output structured
:instructions instr
:data-parts [(streaming/navigate-to-part results-url)]})
(catch Exception e
(log/error e "Error creating SQL query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create SQL query: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "create_sql_query"
:capabilities #{:permission-write-sql-queries}}
create-sql-query-code-edit-tool
"Create a new SQL query and update the code editor buffer."
[{:keys [database_id sql_query]}
:- [:map {:closed true}
[:database_id :int]
[:sql_query :string]]]
(let [result (create-sql-query-tools/create-sql-query
{:database-id database_id
:sql sql_query})
structured (assoc result :result-type :query)
query-id (:query-id result)
instr (instructions/query-created-instructions-for query-id)
buffer-id (first-code-editor-buffer-id)]
(if buffer-id
{:output (format-query-output structured instr {:preamble? true})
:structured-output structured
:instructions instr
:data-parts [(code-edit-part buffer-id (:query-content result))]}
{:output "No active code editor buffer found for SQL editing."})))
(mu/defn ^{:tool-name "edit_sql_query"
:capabilities #{:permission-write-sql-queries}}
edit-sql-query-tool
"Edit an existing SQL query using structured edits."
[{:keys [query_id edits checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:edits [:sequential [:map {:closed true}
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]]
(try
(let [result (edit-sql-query-tools/edit-sql-query
{:query-id query_id
:edits edits
:checklist checklist
:queries-state (shared/current-queries-state)})
structured (assoc result :result-type :query)
qid (:query-id result)
instr (instructions/edit-sql-query-instructions-for qid)
results-url (streaming/query->question-url (:query result))]
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(streaming/navigate-to-part results-url)]})
(catch Exception e
(log/error e "Error editing SQL query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to edit SQL query: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "edit_sql_query"
:capabilities #{:permission-write-sql-queries}}
edit-sql-query-code-edit-tool
"Edit an existing SQL query and update the code editor buffer."
[{:keys [query_id edits checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:edits [:sequential [:map {:closed true}
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]]
(let [result (edit-sql-query-tools/edit-sql-query
{:query-id query_id
:edits edits
:checklist checklist
:queries-state (shared/current-queries-state)})
structured (assoc result :result-type :query)
qid (:query-id result)
instr (instructions/edit-sql-query-instructions-for qid)
buffer-id (first-code-editor-buffer-id)]
(if buffer-id
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(code-edit-part buffer-id (:query-content result))]}
{:output "No active code editor buffer found for SQL editing."})))
(mu/defn ^{:tool-name "replace_sql_query"
:capabilities #{:permission-write-sql-queries}}
replace-sql-query-tool
"Replace the SQL content of an existing query entirely."
[{:keys [query_id new_query checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:new_query :string]]]
(try
(let [result (replace-sql-query-tools/replace-sql-query
{:query-id query_id
:sql new_query
:checklist checklist
:queries-state (shared/current-queries-state)})
structured (assoc result :result-type :query)
qid (:query-id result)
instr (instructions/replace-sql-query-instructions-for qid)
results-url (streaming/query->question-url (:query result))]
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(streaming/navigate-to-part results-url)]})
(catch Exception e
(log/error e "Error replacing SQL query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to replace SQL query: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "replace_sql_query"
:capabilities #{:permission-write-sql-queries}}
replace-sql-query-code-edit-tool
"Replace an SQL query and update the code editor buffer."
[{:keys [query_id new_query checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:new_query :string]]]
(let [result (replace-sql-query-tools/replace-sql-query
{:query-id query_id
:sql new_query
:checklist checklist
:queries-state (shared/current-queries-state)})
structured (assoc result :result-type :query)
qid (:query-id result)
instr (instructions/replace-sql-query-instructions-for qid)
buffer-id (first-code-editor-buffer-id)]
(if buffer-id
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(code-edit-part buffer-id (:query-content result))]}
{:output "No active code editor buffer found for SQL editing."})))

View File

@@ -0,0 +1,44 @@
(ns metabase-enterprise.metabot-v3.agent.tools.subscriptions
"Dashboard subscription tool wrapper."
(:require
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as subscription-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "create_dashboard_subscription"}
create-dashboard-subscription-tool
"Create a dashboard subscription to send regular email updates.
Use when a user wants to receive or send regular updates on a dashboard's contents.
Requires a valid dashboard ID, email address, and schedule.
Do NOT infer email addresses from usernames or other information.
If the email address is incomplete or missing a part like the TLD,
ask the user for clarification before proceeding."
[{:keys [dashboard_id email schedule]}
:- [:map {:closed true}
[:dashboard_id :int]
[:email :string]
[:schedule [:map
[:frequency [:enum "hourly" "daily" "weekly" "monthly"]]
[:hour {:optional true} [:maybe :int]]
[:day_of_week {:optional true} [:maybe :string]]
[:day_of_month {:optional true} [:maybe :string]]]]]]
(try
(subscription-tools/create-dashboard-subscription
{:dashboard-id dashboard_id
:email email
:schedule (-> schedule
(update :frequency keyword)
(cond->
(:day_of_week schedule) (-> (assoc :day-of-week (keyword (:day_of_week schedule)))
(dissoc :day_of_week))
(:day_of_month schedule) (-> (assoc :day-of-month (keyword (:day_of_month schedule)))
(dissoc :day_of_month))))})
(catch Exception e
(log/error e "Error creating dashboard subscription")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create dashboard subscription: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,65 @@
(ns metabase-enterprise.metabot-v3.agent.tools.todo
"Todo tool wrappers."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.todo :as todo-tools]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-todo-write-output
[{:keys [todo_count]}]
(str "Todo list updated successfully. " todo_count " item(s)."))
(defn- format-todo-read-output
[{:keys [todos todo_count]}]
(if (seq todos)
(str "Current todo list (" todo_count " items):\n"
(str/join "\n" (map (fn [{:keys [id content status priority]}]
(str "- [" status "] " content " (id=" id ", priority=" priority ")"))
todos)))
"No todos found."))
(defn- add-output
[result format-fn]
(if-let [structured (:structured-output result)]
(assoc result :output (format-fn structured))
result))
(mu/defn ^{:tool-name "todo_write"} todo-write-tool
"Create and manage a structured task list.
Write or update the todo list with tasks for tracking progress.
Each todo item must have:
- id: Unique identifier for the task
- content: Description of what needs to be done
- status: One of 'pending', 'in_progress', 'completed', 'cancelled'
- priority: One of 'high', 'medium', 'low'"
[{:keys [todos]}
:- [:map {:closed true}
[:todos [:sequential [:map {:closed true}
[:id :string]
[:content :string]
[:status [:enum "pending" "in_progress" "completed" "cancelled"]]
[:priority [:enum "high" "medium" "low"]]]]]]]
(try
(add-output
(todo-tools/todo-write {:todos todos
:memory-atom shared/*memory-atom*})
format-todo-write-output)
(catch Exception e
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to update todo list: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "todo_read"} todo-read-tool
"Read the current todo list from memory.
Returns the list of todos that have been created during this conversation."
[_args :- [:maybe [:map {:closed true}]]]
(try
(add-output
(todo-tools/todo-read {:memory-atom shared/*memory-atom*})
format-todo-read-output)
(catch Exception e
{:output (str "Failed to read todo list: " (or (ex-message e) "Unknown error"))})))

View File

@@ -0,0 +1,185 @@
(ns metabase-enterprise.metabot-v3.agent.tools.transforms
"Transform tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.dependencies :as deps]
[metabase-enterprise.metabot-v3.tools.transforms :as transform-tools]
[metabase-enterprise.metabot-v3.tools.transforms-write :as transforms-write-tools]
[metabase-enterprise.metabot-v3.util :as metabot.u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-transform-details-output
[{:keys [id name description source target] :as _transform}]
(metabot.u/xml
[:transform {:id id :name name}
(when description [:description description])
(when source
[:source {:type (:type source)}
(when (:query source) [:query (:query source)])
(when (:source-database source) [:database (:source-database source)])])
(when target [:target (pr-str target)])]))
(defn- format-python-library-output
[{:keys [path] :as lib}]
(metabot.u/xml
[:python-library {:path path}
(when-let [content (:content lib)] [:content content])]))
(defn- format-transform-write-output
[{:keys [message]}]
(or message "Transform updated successfully."))
(defn- add-output
"Add :output to a tool result. Handles both :structured_output and :structured-output."
[result format-fn]
(if-let [structured (or (:structured_output result) (:structured-output result))]
(assoc result :output (format-fn structured))
result))
(defn- check-dependencies
"Check for downstream breakages after a SQL transform edit.
Returns nil if no issues found or the check fails, otherwise a map with
:bad_transforms and :bad_questions."
[transform-id source]
(when transform-id
(try
(let [{:keys [structured_output]} (deps/check-transform-dependencies
{:id transform-id :source source})]
(when (and structured_output
(or (seq (:bad_transforms structured_output))
(seq (:bad_questions structured_output))))
structured_output))
(catch Exception e
(log/error e "Dependency check failed for transform" transform-id)
nil))))
(defn- format-dependency-warnings
"Format dependency check results into instructions for the agent."
[{:keys [bad_transforms bad_questions]}]
(str "\n\n**Dependency issues detected.**\n"
"Your proposed changes would break downstream dependencies.\n\n"
(when (seq bad_transforms)
(str "Broken transforms:\n"
(apply str (for [{:keys [transform]} bad_transforms]
(str "- [" (:name transform) "](metabase://transform/" (:id transform) ")\n")))))
(when (seq bad_questions)
(str "Broken questions:\n"
(apply str (for [{:keys [question]} bad_questions]
(str "- [" (:name question) "](metabase://question/" (:id question) ")\n")))))
"\nYou must summarize these dependency issues for the user. "
"Do not propose or apply fixes yourself."))
(mu/defn ^{:tool-name "get_transform_details"} get-transform-details-tool
"Get information about a transform."
[{:keys [transform_id]} :- [:map {:closed true}
[:transform_id :int]]]
(add-output (transform-tools/get-transform-details {:transform-id transform_id})
format-transform-details-output))
(mu/defn ^{:tool-name "get_transform_python_library_details"} get-transform-python-library-details-tool
"Get information about a Python library by path."
[{:keys [path]} :- [:map {:closed true}
[:path :string]]]
(add-output (transform-tools/get-transform-python-library-details {:path path})
format-python-library-output))
(mu/defn ^{:tool-name "write_transform_sql"
:capabilities #{:feature-transforms :permission-write-transforms}}
write-transform-sql-tool
"Write new SQL queries or edit existing queries for transforms.
Supports two modes:
- edit: Targeted string replacements with partial edits
- replace: Replace entire query atomically
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
For replace mode, provide new_content with the complete SQL."
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables]}
:- [:map {:closed true}
[:transform_id {:optional true} [:maybe :int]]
[:edit_action [:map
[:mode [:enum "edit" "replace"]]
[:edits {:optional true} [:maybe [:sequential [:map
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]
[:new_content {:optional true} [:maybe :string]]]]
[:thinking {:optional true} [:maybe :string]]
[:transform_name {:optional true} [:maybe :string]]
[:transform_description {:optional true} [:maybe :string]]
[:source_database {:optional true} [:maybe :int]]
[:source_tables {:optional true} [:maybe :map]]]]
(try
(let [result (add-output
(transforms-write-tools/write-transform-sql
{:transform_id transform_id
:edit_action edit_action
:thinking thinking
:transform_name transform_name
:transform_description transform_description
:source_database source_database
:source_tables source_tables
:memory-atom shared/*memory-atom*
:context (shared/current-context)})
format-transform-write-output)
transform (get-in result [:structured-output :transform])
dep-issues (check-dependencies transform_id (:source transform))]
(cond-> result
dep-issues (update :instructions str (format-dependency-warnings dep-issues))))
(catch Exception e
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to write SQL transform: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "write_transform_python"
:capabilities #{:feature-transforms :feature-transforms-python :permission-write-transforms}}
write-transform-python-tool
"Write new Python code or edit existing code for transforms.
Supports two modes:
- edit: Targeted string replacements with partial edits
- replace: Replace entire code atomically
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
For replace mode, provide new_content with the complete Python code.
Use `get_transform_python_library_details` before writing any Python code to inspect the shared library.
Use the shared library in your code by adding `import common` at the top of the file.
Keep `import common` at the top of the file even if it is currently unused."
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables]}
:- [:map {:closed true}
[:transform_id {:optional true} [:maybe :int]]
[:edit_action [:map
[:mode [:enum "edit" "replace"]]
[:edits {:optional true} [:maybe [:sequential [:map
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]
[:new_content {:optional true} [:maybe :string]]]]
[:thinking {:optional true} [:maybe :string]]
[:transform_name {:optional true} [:maybe :string]]
[:transform_description {:optional true} [:maybe :string]]
[:source_database {:optional true} [:maybe :int]]
[:source_tables {:optional true} [:maybe :map]]]]
(try
(add-output
(transforms-write-tools/write-transform-python
{:transform_id transform_id
:edit_action edit_action
:thinking thinking
:transform_name transform_name
:transform_description transform_description
:source_database source_database
:source_tables source_tables
:memory-atom shared/*memory-atom*
:context (shared/current-context)})
format-transform-write-output)
(catch Exception e
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to write Python transform: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,254 @@
(ns metabase-enterprise.metabot-v3.agent.user-context
"User context enrichment and formatting for agent system messages.
Handles formatting of viewing context (what the user is currently looking at),
recent views, user time formatting, and SQL dialect extraction from context."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.util :as u]
[metabase.util.log :as log])
(:import
(java.time OffsetDateTime)
(java.time.format DateTimeFormatter)))
(set! *warn-on-reflection* true)
;;; Time Formatting
(defn format-current-time
"Format user's current time from context.
Expects :current_user_time (preferred) or :current_time_with_timezone in context as ISO-8601 string."
[context]
(try
(cond
(string? (:current_user_time context))
(:current_user_time context)
(string? (:current_time_with_timezone context))
(let [offset-time (OffsetDateTime/parse (:current_time_with_timezone context))
formatter DateTimeFormatter/ISO_LOCAL_DATE_TIME]
(.format formatter offset-time))
(string? (:current_time context))
(:current_time context)
:else
(.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (OffsetDateTime/now)))
(catch Exception e
(log/error e "Error formatting current time")
(.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (OffsetDateTime/now)))))
;;; SQL Dialect Extraction
(declare normalize-context-type native-query-item? effective-context-type)
(defn extract-sql-dialect
"Extract SQL dialect name from context.
Looks for dialect in native SQL editor context (:sql_engine field).
Handles both explicit `type: \"native\"` items and `type: \"adhoc\"` items
whose inner dataset-query type is `\"native\"`.
Returns lowercase dialect name suitable for loading dialect instructions."
[context]
(when-let [viewing (:user_is_viewing context)]
(some (fn [item]
(when (= "native" (effective-context-type item))
(some-> (:sql_engine item) u/lower-case-en)))
viewing)))
;;; Context Normalization
(defn- normalize-context-type
"Normalize context :type to lowercase string."
[type-val]
(cond
(keyword? type-val) (name type-val)
(string? type-val) type-val
(nil? type-val) nil
:else (str type-val)))
(defn- native-query-item?
"True when the viewing-context item represents a native SQL query.
The frontend sends `type: \"adhoc\"` for *both* notebook (MBQL) and native SQL
queries. We distinguish them by inspecting the query: a dataset-query with
`{:type \"native\"}` (or `:native`) is a native SQL query, as is an MLv2/pMBQL
query with a single native stage."
[item]
(let [query (:query item)]
(or (= "native" (normalize-context-type (:type query)))
;; MLv2/pMBQL: normalize and use lib to detect native queries
(when (and (map? query) (:database query))
(try
(lib/native-only-query? (lib-be/normalize-query query))
(catch Exception _ false))))))
(defn- effective-context-type
"Return the effective context type for a viewing-context item.
Handles the fact that the frontend sends `type: \"adhoc\"` for both notebook
and native SQL queries by inspecting the inner dataset-query type."
[item]
(let [t (normalize-context-type (:type item))]
(if (and (= "adhoc" t) (native-query-item? item))
"native"
t)))
;;; Entity Formatting
(defn- fully-qualified-name
"Build fully qualified table name (schema.table) when schema is available."
[{:keys [name database_schema]}]
(if (not-empty database_schema)
(str database_schema "." name)
name))
(defn- format-simple-entity
[entity]
(te/lines
(te/field (:type entity) (str (fully-qualified-name entity) " (ID: " (or (:id entity) "-") ")"))
(te/field "Description" (:description entity))
(te/field "Fields" (some->> (:fields entity) (map :name) (str/join ", ")))))
(defn- dispatch-format-entity [entity] (effective-context-type entity))
(defmulti format-entity "Format an entity for LLM representation." {:arglists '([entity])} dispatch-format-entity)
(defmethod format-entity :default [entity]
(log/warn "Unknown viewing context type:" (:type entity))
"")
(defmethod format-entity "table" [entity] (format-simple-entity entity))
(defmethod format-entity "model" [entity] (format-simple-entity entity))
(defmethod format-entity "question" [entity] (if (native-query-item? entity) (format-entity "native" entity) (format-simple-entity entity)))
(defmethod format-entity "metric" [entity] (format-simple-entity entity))
(defmethod format-entity "dashboard" [entity] (format-simple-entity entity))
;;; Viewing Context Formatting
;; Format adhoc query (notebook editor) viewing context.
(defmethod format-entity "adhoc"
[item]
(te/lines "The user is currently in the notebook editor viewing a query."
(te/field "Query ID" (:id item))
(te/field "Database ID" (get-in item [:query :database]))
(te/field "Tables used" (some->> (:used_tables item)
(map format-entity)
te/lines))))
;; Format native SQL query viewing context.
;; The :query field can be either a plain SQL string (legacy / explicit `type: "native"`)
;; or a dataset-query map (from the frontend with `type: "adhoc"`) where the actual SQL
;; lives at [:native :query].
(defmethod format-entity "native"
[item]
(let [query-val (:query item)
sql-text (cond
;; Plain SQL string (legacy)
(string? query-val) query-val
;; MLv2/pMBQL or legacy dataset-query map: normalize and use lib
(and (map? query-val) (:database query-val))
(try
(lib/raw-native-query (lib-be/normalize-query query-val))
(catch Exception _
;; Fall back to manual extraction
(or (some :native (:stages query-val))
(get-in query-val [:native :query]))))
;; Other map shapes
(map? query-val) (get-in query-val [:native :query])
:else nil)]
(te/lines
"The user is currently in the SQL editor."
(te/field "Query ID" (:id item))
(te/field "Current SQL query" (te/code sql-text "sql"))
(te/field "Database SQL engine" (:sql_engine item))
(te/field "Query error" (te/code (:error item)))
(te/field "Tables used" (some->> (:used_tables item)
(map format-entity)
te/lines)))))
(defmethod format-entity "transform"
[item]
(te/lines "The user is currently viewing a Transform."
(te/field "Transform ID" (:id item))
(te/field "Transform name" (:name item))
(te/field "Source type" (:source_type item))
(te/field "Transform error" (te/code (:error item)))
(te/field "Tables used" (some->> (:used_tables item)
(map format-entity)
te/lines))))
(defmethod format-entity "code_editor"
[{:keys [buffers]}]
(if (empty? buffers)
"The user is in the code editor but no active buffers are available."
(te/lines "The user is currently in the code editor with the following buffer(s):"
(for [{:keys [source cursor selection] :as buffer} buffers]
(te/lines
(format "Buffer ID: %s | Language: %s | Database ID: %s"
(:id buffer) (:language source) (:database_id source))
(when cursor
(format "Cursor: Line %s, Column %s" (:line cursor) (:column cursor)))
(when-let [{:keys [start end text]} selection]
(te/lines
(te/field "Selected lines" (str (:line start) "-" (:line end)))
(te/field "Selected text" text))))))))
(defn format-viewing-context
"Format user's current viewing context for injection into system message.
Handles different context types:
- adhoc: Notebook query editor
- native: SQL editor with schema context
- transform: Transform definition and code
- code_editor: Code editor buffers with cursor position
- table/model/question/metric/dashboard: Entity details
Returns formatted string for template variable {{viewing_context}}."
[context]
(str/join "\n\n"
(for [item (:user_is_viewing context)]
(try
(format-entity item)
(catch Exception e
(log/error e "Error formatting viewing context item:" (:type item))
"")))))
;;; Recent Views Formatting
(defn format-recent-views
"Format user's recently viewed items for injection into system message.
Returns formatted string for template variable {{recent_views}}."
[context]
(if-not (:user_recently_viewed context)
""
(let [items (:user_recently_viewed context)]
(te/lines "Here are some items the user has recently viewed:"
(for [item items]
(format-simple-entity (select-keys item [:type :id :name :description])))
""
"**Important:** These items might be relevant for answering the user's request."
"If any item seems relevant, try to fetch its full details using the appropriate tool."
"Otherwise, use the search tool to find relevant entities."))))
;;; Context Enrichment
(defn enrich-context-for-template
"Enrich context with all necessary variables for system prompt template rendering.
Takes raw context from API and returns map suitable for template rendering:
- :current_time - Formatted user time string
- :first_day_of_week - Calendar week start (default 'Sunday')
- :sql_dialect - SQL dialect name (lowercase)
- :viewing_context - Formatted viewing context
- :recent_views - Formatted recent views"
[context]
{:current_time (format-current-time context)
:first_day_of_week (get context :first_day_of_week "Sunday")
:sql_dialect (extract-sql-dialect context)
:viewing_context (format-viewing-context context)
:recent_views (format-recent-views context)})

View File

@@ -2,7 +2,9 @@
"`/api/ee/metabot-v3/` routes"
(:require
[clj-http.client :as http]
[clojure.core.async :as a]
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.core :as agent]
[metabase-enterprise.metabot-v3.api.document]
[metabase-enterprise.metabot-v3.api.metabot]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
@@ -10,6 +12,7 @@
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
[metabase-enterprise.metabot-v3.self.core :as self.core]
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
[metabase.api.common :as api]
@@ -17,15 +20,23 @@
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.app-db.core :as app-db]
[metabase.config.core :as config]
[metabase.premium-features.core :as premium-features]
[metabase.server.streaming-response :as sr]
[metabase.store-api.core :as store-api]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
[toucan2.core :as t2])
(:import
(java.io OutputStream)))
(defn- store-message! [conversation-id profile-id messages]
(set! *warn-on-reflection* true)
(defn- store-aiservice-messages!
"Store messages that are going from ai-service"
[conversation-id profile-id messages]
(let [finish (let [m (u/last messages)]
(when (= (:_type m) :FINISH_MESSAGE)
m))
@@ -50,7 +61,6 @@
(filter map?)
(map #(+ (:prompt %) (:completion %)))
(apply +))})))
(defn- check-metabot-enabled!
"Checks that the Metabot instance identified by `metabot-id` is enabled. Throws a 403 if it's not."
[metabot-id]
@@ -60,27 +70,170 @@
(api/check (metabot-v3.settings/metabot-enabled?)
[403 "Metabot is not enabled."])))
(defn- extract-usage
"Extract usage from parts, taking the last `:usage` per model.
The agent loop emits cumulative usage — each `:usage` part subsumes all prior
usage for that model — so we simply take the last one per model rather than
summing. Returns a map keyed by model name:
{\"model-name\" {:prompt X :completion Y}}"
[parts]
(transduce
(filter #(= :usage (:type %)))
(completing
(fn [acc {:keys [usage model]}]
(let [model (or model "unknown")]
(assoc acc model {:prompt (:promptTokens usage 0)
:completion (:completionTokens usage 0)}))))
{}
parts))
(defn- store-native-parts!
"Store assistant response parts directly to the database.
Takes AI SDK parts (after aisdk-xf combining) and stores them in the native format,
avoiding the intermediate 'aisdk messages' format.
Parts format: [{:type :text :text \"...\"} {:type :tool-input ...} ...]"
[conversation-id profile-id parts]
(let [state-part (u/seek #(and (= :data (:type %))
(= "state" (:data-type %)))
parts)
usage (extract-usage parts)
;; Filter out :start, :usage, :finish, :data - these are metadata, not message content
;; :data is like `:navigate_to`
content (->> parts
(remove #(#{:start :usage :finish :data} (:type %)))
vec)]
(t2/with-transaction [_conn]
(when state-part
(app-db/update-or-insert! :model/MetabotConversation {:id conversation-id}
(constantly {:user_id api/*current-user-id*
:state (:data state-part)})))
(t2/insert! :model/MetabotMessage
{:conversation_id conversation-id
:data content
:usage usage
:role :assistant
:profile_id profile-id
:total_tokens (->> (vals usage)
(map #(+ (:prompt %) (:completion %)))
(reduce + 0))}))))
(defn- streaming-writer-rf
"Creates a reducing function that writes AI SDK lines to an OutputStream.
Lines are written immediately with a newline and flushed for real-time streaming.
When `canceled-chan` is provided, polls it before each write and returns `reduced`
to stop the pipeline when the client has disconnected. Also catches EofException
(client closed connection) and converts it to `reduced` so the pipeline shuts down
cleanly without triggering upstream retries."
[^java.io.OutputStream os canceled-chan]
(fn
([] nil)
([_] nil)
([acc ^String line]
(if (and canceled-chan (a/poll! canceled-chan))
(reduced acc)
(try
(.write os (.getBytes (str line "\n") "UTF-8"))
(.flush os)
(catch org.eclipse.jetty.io.EofException _
(reduced acc)))))))
(defn- combine-text-parts-xf []
(fn [rf]
(let [pending (volatile! nil)]
(fn
([] (rf))
([result]
(let [p @pending]
(rf (if p (rf result p) result))))
([result part]
(let [prev @pending]
(if (and prev (= :text (:type prev) (:type part)))
(do (vswap! pending update :text str (:text part))
result)
(do (vreset! pending part)
(if prev (rf result prev) result)))))))))
(defn- native-agent-streaming-request
"Handle streaming request using native Clojure agent.
Streams AI SDK v4 line protocol to the client in real-time while simultaneously
collecting parts for database storage. Text parts are combined before storage
to consolidate streaming chunks into single text parts.
Monitors `canceled-chan` for client disconnection — when the client closes the
connection, the pipeline stops via `reduced` and collected parts are still persisted.
When `:debug?` is true, enables debug logging which emits a `debug_log` data
part at the end of the stream with full LLM request/response data per iteration."
[{:keys [profile-id message context history conversation-id state debug?]}]
(let [enriched-context (metabot-v3.context/create-context context)
messages (concat history [message])]
(sr/streaming-response {:content-type "text/event-stream"} [^OutputStream os canceled-chan]
(let [parts-atom (atom [])
;; Compose: collect parts AND convert to lines for streaming.
;; In dev mode, emit usage parts in the SSE stream for debugging/benchmarking.
xf (comp (u/tee-xf parts-atom)
(self.core/aisdk-line-xf {:emit-usage? config/is-dev?}))]
(try
(transduce xf
(streaming-writer-rf os canceled-chan)
(agent/run-agent-loop
(cond-> {:messages messages
:state state
:profile-id (keyword profile-id)
:context enriched-context
:tracking-opts {:session-id conversation-id
:track-user-intent? true}}
debug? (assoc :debug? true))))
(catch org.eclipse.jetty.io.EofException _
(log/debug "Client disconnected during native agent streaming"))
(finally
(store-native-parts! conversation-id profile-id (into [] (combine-text-parts-xf) @parts-atom))))))))
(defn streaming-request
"Handles an incoming request, making all required tool invocation, LLM call loops, etc."
[{:keys [metabot_id profile_id message context history conversation_id state]}]
[{:keys [metabot_id profile_id message context history conversation_id state debug]}]
(let [message (metabot-v3.envelope/user-message message)
metabot-id (metabot-v3.config/resolve-dynamic-metabot-id metabot_id)
_ (check-metabot-enabled! metabot-id)
profile-id (metabot-v3.config/resolve-dynamic-profile-id profile_id metabot-id)
session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(store-message! conversation_id profile-id [message])
(metabot-v3.client/streaming-request
{:context (metabot-v3.context/create-context context)
:metabot-id metabot-id
:profile-id profile-id
:session-id session-id
:conversation-id conversation_id
:message message
:history history
:state state
:on-complete (fn [lines]
(store-message! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
:store-in-db)})))
;; Only allow debug mode in dev — never in production
debug? (and config/is-dev? (boolean debug))]
(store-aiservice-messages! conversation_id profile-id [message])
(if (metabot-v3.settings/use-native-agent)
;; Use native Clojure agent
(do
(log/info "Using native Clojure agent" {:profile-id profile-id :debug? debug?})
(native-agent-streaming-request
{:profile-id profile-id
:message message
:context context
:history history
:conversation-id conversation_id
:state state
:debug? debug?}))
;; Fallback to Python AI Service
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(log/info "Using Python AI Service" {:profile-id profile-id :debug? debug?})
(metabot-v3.client/streaming-request
{:context (metabot-v3.context/create-context context)
:metabot-id metabot-id
:profile-id profile-id
:session-id session-id
:conversation-id conversation_id
:message message
:history history
:state state
:debug? debug?
:on-complete (fn [lines]
(store-aiservice-messages! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
:store-in-db)})))))
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
@@ -97,7 +250,8 @@
[:context ::metabot-v3.context/context]
[:conversation_id ms/UUIDString]
[:history [:maybe ::metabot-v3.client.schema/messages]]
[:state :map]]]
[:state :map]
[:debug {:optional true} [:maybe :boolean]]]]
(metabot-v3.context/log body :llm.log/fe->be)
(streaming-request body))

View File

@@ -2,7 +2,9 @@
"`/api/ee/metabot-v3/document` routes"
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.core :as metabot-v3.agent]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
@@ -32,6 +34,109 @@
(catch Exception e
(ex-message e))))
(defn- maybe-fix-native-sql
[response references]
(let [query (get-in response [:draft_card :dataset_query])]
(if-let [error (when (= "native" (name (:type query))) (check-query query))]
(let [db-id (-> references
keys
first
name
(#(str/split % #":"))
last
Integer/parseInt)
db (warehouses/get-database db-id)
schema-ddl (table-utils/schema-full db-id)]
;; Attempt to fix SQL 5 times before returning.
(loop [attempts-left 5
sql (get-in query [:native :query])
error error]
(let [fixed-sql (fix-sql {:sql sql
:dialect (:engine db)
:error_message error
:schema_ddl schema-ddl})
;; Only re-check query if new SQL was produced.
error (when fixed-sql
(check-query (assoc-in query [:native :query] fixed-sql)))]
(if (and error (> attempts-left 1))
(recur (dec attempts-left) fixed-sql error)
(assoc-in response [:draft_card :dataset_query :native :query] (or fixed-sql sql))))))
response)))
(defn- part->structured-output
[part]
(or (get-in part [:result :structured-output])
(get-in part [:result :structured_output])))
(defn- latest-chart-structured-output
[parts]
(->> parts
(filter #(= :tool-output (:type %)))
(keep part->structured-output)
(filter map?)
(filter #(or (:chart-id %)
(= :chart-draft (:result-type %))
(and (:dataset_query %) (:display %))))
last))
(defn- draft-card-from-chart-output
[chart-output]
(let [chart-name (:name chart-output)
query (:dataset_query chart-output)
chart-type (or (:display chart-output)
(:chart_type chart-output))]
(when (and chart-name (map? query) chart-type)
{:name chart-name
:display (name chart-type)
:dataset_query query
:database_id (:database query)
:parameters []
:visualization_settings {}})))
(defn- last-agent-message
[parts]
(let [text-groups (reduce (fn [groups part]
(if (= :text (:type part))
(update groups (dec (count groups)) conj (:text part))
(conj groups [])))
[[]]
parts)
last-text-message (some->> text-groups
reverse
(map #(str/trim (str/join "" %)))
(remove str/blank?)
first)]
last-text-message))
(defn- native-generate-content
[{:keys [instructions references]}]
(let [context (assoc
(metabot-v3.context/create-context {:capabilities #{"permission:write_sql_queries"}})
:references references)
parts (into [] (metabot-v3.agent/run-agent-loop
{:messages [{:role :user
:content instructions}]
:profile-id :document-generate-content
:state {}
:context context
:tracking-opts {:source "document_generate_content"
:track-user-intent? true}}))
chart-output (latest-chart-structured-output parts)
draft-card (draft-card-from-chart-output chart-output)
description (or (:description chart-output)
(:name chart-output)
(:name draft-card))]
(if draft-card
(maybe-fix-native-sql {:draft_card draft-card
:description description
:error nil} references)
{:draft_card nil
:description nil
:error (or
(last-agent-message parts)
"Unable to generate chart content.")})))
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
@@ -47,35 +152,26 @@
(let [response (metabot-v3.client/document-generate-content {:instructions (:instructions body)
:references (:references body)
:user_id api/*current-user-id*
:conversation_id (str (random-uuid))})
query (get-in response [:draft_card :dataset_query])]
(if (and query (= "native" (:type query)))
(if-let [error (check-query query)]
(let [db-id (-> (:references body)
keys
first
name
(#(str/split % #":"))
last
Integer/parseInt)
db (warehouses/get-database db-id)
schema-ddl (table-utils/schema-full db-id)]
;; Attempt to fix SQL 5 times before returning.
(loop [attempts-left 5
sql (get-in query [:native :query])
error error]
(let [fixed-sql (fix-sql {:sql sql
:dialect (:engine db)
:error_message error
:schema_ddl schema-ddl})
;; Only re-check query if new SQL was produced.
error (when fixed-sql
(check-query (assoc-in query [:native :query] fixed-sql)))]
(if (and error (> attempts-left 1))
(recur (dec attempts-left) fixed-sql error)
(assoc-in response [:draft_card :dataset_query :native :query] (or fixed-sql sql))))))
response)
response)))
:conversation_id (str (random-uuid))})]
(maybe-fix-native-sql response (:references body))))
(api.macros/defendpoint :post "/native-generate-content" :- [:map
[:draft_card [:maybe [:map
[:name ms/NonBlankString]
[:dataset_query ms/Map]
[:database_id ms/PositiveInt]
[:parameters [:maybe [:sequential ms/Map]]]
[:visualization_settings ms/Map]]]]
[:error [:maybe ms/NonBlankString]]
[:description [:maybe ms/NonBlankString]]]
"Create a new piece of content to insert into the document using the native Clojure agent."
[_route-params
_query-params
body :- [:map
[:instructions ms/NonBlankString]
[:references {:optional true} ms/Map]]]
(native-generate-content body))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3/document` routes."

View File

@@ -94,9 +94,6 @@
(defn- metric-selection-endpoint-url []
(str (metabot-v3.settings/ai-service-base-url) "/v1/select-metric"))
(defn- find-outliers-endpoint-url []
(str (metabot-v3.settings/ai-service-base-url) "/v1/find-outliers"))
(defn- fix-sql-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/sql/fix"))
@@ -115,9 +112,6 @@
(defn- document-generate-content-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/document/generate-content"))
(defn- generate-embeddings-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/embeddings"))
(defn- quick-closing-body
"Some requests come with body wrapped in ContentLengthInputStream, and that will never close the underlying stream.
So we just close the client itself.
@@ -147,7 +141,7 @@
Response chunks are encoded in the format understood by the frontend and AI Service, Clojure backend doesn't
know anything about it and just shuttles them over.
"
[{:keys [context message history profile-id conversation-id session-id state on-complete]}
[{:keys [context message history profile-id conversation-id session-id state debug? on-complete]}
:- [:map
[:context :map]
[:message ::metabot-v3.client.schema/message]
@@ -156,16 +150,18 @@
[:conversation-id :string]
[:session-id :string]
[:state :map]
[:debug? {:optional true} [:maybe :boolean]]
[:on-complete {:optional true} [:function [:=> [:cat :any] :any]]]]]
(premium-features/assert-has-feature :metabot-v3 "MetaBot")
(try
(let [url (ai-url "/v2/agent/stream")
body {:messages (conj (vec history) message)
:context context
:conversation_id conversation-id
:profile_id profile-id
:user_id api/*current-user-id*
:state state}
body (cond-> {:messages (conj (vec history) message)
:context context
:conversation_id conversation-id
:profile_id profile-id
:user_id api/*current-user-id*
:state state}
debug? (assoc :debug true))
_ (metabot-v3.context/log body :llm.log/be->llm)
_ (log/debugf "V2 request to AI Proxy:\n%s" (u/pprint-to-str body))
options (cond-> {:headers {"Accept" "text/event-stream"
@@ -241,21 +237,6 @@
{}
e)))))
(mu/defn find-outliers-request
"Make a request to AI Service to find outliers"
[values :- [:sequential [:map [:dimension :any] [:value :any]]]]
(try
(let [url (find-outliers-endpoint-url)
body {:values values}
options (build-request-options body)
response (post! url options)]
(u/prog1 (check-response! response body)
(log/debugf "Response:\n%s" (u/pprint-to-str <>))))
(catch Throwable e
(throw (ex-info (format "Error in request to AI service: %s" (ex-message e))
{}
e)))))
(mu/defn fix-sql
"Ask the AI service to propose fixes a SQL query and a given error."
[body :- [:map
@@ -373,14 +354,3 @@
options (build-request-options payload)
response (post! url options)]
(check-response! response payload)))
(defn generate-embeddings
"Generate vector embeddings for a batch of inputs questions for the given models and metrics."
[model-name texts]
(let [url (generate-embeddings-endpoint)
body {:model model-name
:input texts
:encoding_format "base64"}
options (build-request-options body)
response (post! url options)]
(check-response! response body)))

View File

@@ -16,7 +16,7 @@
"The name of the collection exposed by the answer-sources tool."
{internal-metabot-id {:profile-id "internal"
:entity-id "metabotmetabotmetabot"}
embedded-metabot-id {:profile-id "embedding"
embedded-metabot-id {:profile-id "embedding_next"
:entity-id "embeddedmetabotmetabo"}})
(defn metabot-id->profile-id
@@ -45,11 +45,11 @@
(defn resolve-dynamic-profile-id
"Resolve the ultimate ai-service profile ID with logical fall backs
Precedence: explicit profile_id > env profile_id > metabot-id->profile-id > default (embedded)"
Precedence: explicit profile_id > env profile_id > metabot-id->profile-id > default (embedding_next)"
([profile-id]
(resolve-dynamic-profile-id profile-id (resolve-dynamic-metabot-id nil)))
([profile-id metabot-id]
(or profile-id
(metabot-v3.settings/ai-service-profile-id)
(metabot-id->profile-id metabot-id)
"default")))
"embedding_next")))

View File

@@ -12,7 +12,8 @@
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr])
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2])
(:import
(java.time OffsetDateTime)
(java.time.format DateTimeFormatter)))
@@ -114,8 +115,47 @@
(log/error e "Error getting Python transform tables for context")
[])))
(defn- mbql-source-table-ids
"Given a context item with an MBQL query, return [database-id [table-id ...]] if it has
source-table references, or nil otherwise. Handles both MLv2/pMBQL and legacy formats."
[item]
(when (= "adhoc" (:type item))
(let [query (:query item)
database-id (:database query)
normalized (try (lib-be/normalize-query query) (catch Exception _ nil))
table-ids (->> (:stages normalized)
(keep :source-table)
(filter pos-int?)
distinct
vec)]
(when (seq table-ids)
[database-id table-ids]))))
(defn- mbql-source-tables-for-context
"Get source tables for an MBQL query, formatted for metabot context (with :type, :display_name, :fields, etc.).
Uses a direct table lookup without native-query permission checks. The user is already
viewing these tables in the notebook editor, so they have at least query-builder access.
The standard `used-tables-from-ids` requires `:query-builder-and-native` permissions
which is too restrictive for MBQL viewing context enrichment."
[[database-id table-ids]]
(try
(let [raw-tables (t2/select [:model/Table :id :name :schema]
:db_id database-id
:id [:in table-ids]
:active true
:visibility_type nil)
tables (when (seq raw-tables)
(table-utils/enhanced-database-tables database-id
{:priority-tables raw-tables
:all-tables-limit (count raw-tables)}))]
(m/distinct-by :id tables))
(catch Exception e
(log/error e "Error getting MBQL source tables for context")
nil)))
(defn- enhance-context-with-schema
"Enhance context by adding table schema information for native queries, SQL transforms, and Python transforms"
"Enhance context by adding table schema information for native queries, MBQL queries, SQL transforms, and Python transforms."
[context]
(if-let [user-viewing (get context :user_is_viewing)]
(let [enhanced-viewing
@@ -126,6 +166,11 @@
(when-let [tables (seq (database-tables-for-context {:query query}))]
(assoc item :used_tables tables)))
;; Handle MBQL/notebook queries
(when-let [db-and-table-ids (mbql-source-table-ids item)]
(when-let [tables (seq (mbql-source-tables-for-context db-and-table-ids))]
(assoc item :used_tables tables)))
;; Handle Python transforms
(when-let [db-and-table-ids (python-transform-db-and-table-ids item)]
(when-let [tables (seq (python-transform-tables-for-context db-and-table-ids))]

View File

@@ -0,0 +1,185 @@
(ns metabase-enterprise.metabot-v3.example-question-generator
"Native Clojure generator for example questions (prompts) for Metabot.
Replaces the Python ai-service `/v1/example-question-generation/batch` endpoint
when `use-native-agent` is true.
Architecture mirrors the Python service:
- Separate LLM call per table/metric (not one batch prompt)
- Two distinct prompt templates: table.jinja → table.selmer, metric.jinja → metric.selmer
- Parallel execution within batches of 10
- 5 questions generated per item
- Uses tool_choice pattern for structured output (matching Python's structured_completion)
- Temperature 0.3 (matching Python default)
- Response shape matches: `{:table_questions [{:questions [...]}] :metric_questions [{:questions [...]}]}`"
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.util.log :as log]
[selmer.parser :as selmer]))
(set! *warn-on-reflection* true)
;;; Template loading (cached)
(def ^:private table-template-path "metabot/prompts/example_questions_table.selmer")
(def ^:private metric-template-path "metabot/prompts/example_questions_metric.selmer")
(def ^:private template-cache
"In-memory cache for loaded prompt templates. Map of path -> template string."
(atom {}))
(defn- load-template [path]
(or (get @template-cache path)
(if-let [content (some-> (io/resource path) slurp)]
(do (swap! template-cache assoc path content)
content)
(throw (ex-info "Prompt template not found" {:path path})))))
(defn clear-template-cache!
"Clear the template cache. Useful for development/testing."
[]
(reset! template-cache {}))
;;; Column LLM name formatting (mirrors Python ColumnSchema.get_llm_name / get_llm_description)
(defn- llm-name
"Format column name for LLM. If it has a table-reference, format as table_reference__name."
[{:keys [name table-reference]}]
(if (not (str/blank? table-reference))
(str table-reference "__" name)
name))
(defn- llm-description
"Format column description for LLM. If it has a table-reference, prepend 'From the related X table.'"
[{:keys [description table-reference]}]
(cond
(and (not (str/blank? table-reference)) (not (str/blank? description)))
(str "From the related " table-reference " table. " description)
(not (str/blank? table-reference))
(str "From the related " table-reference " table.")
:else
(or description "")))
(defn- enrich-column
"Add :llm_name and :llm_description to a column map for template rendering."
[col]
(assoc col
:llm_name (llm-name col)
:llm_description (llm-description col)))
;;; Output validation
(def ^:private questions-json-schema
"JSON Schema for the structured output tool, matching Python's ExampleQuestionGenerationSchema."
{:type "object"
:properties {:questions {:type "array"
:items {:type "string"}
:description "List of example questions. No additional explanation."}}
:required ["questions"]
:additionalProperties false})
(defn- validate-questions-response!
"Validate that LLM response has the expected shape: {:questions [string ...]}.
Throws on invalid response so fallback can kick in."
[response source-name]
(when-not (and (map? response)
(sequential? (:questions response))
(every? string? (:questions response)))
(throw (ex-info "Invalid LLM response shape for example question generation"
{:response response
:source source-name
:expected "{:questions [\"string\" ...]}"}))))
;;; LLM call
(def ^:private temperature 0.3)
(defn- call-llm
"Make a structured LLM call for example question generation.
Delegates to the shared self/call-llm-structured infrastructure which provides
retry logic, error handling, and OTel tracing."
[rendered-prompt]
(self/call-llm-structured
(llm/ee-ai-metabot-provider)
[{:role "user" :content rendered-prompt}]
questions-json-schema
temperature
300
{:request-id (str (random-uuid))
;; example_question_generation_batch was the name of the old ai-service api endpoint
:source "example_question_generation_batch"
:tag "example-question-generation"}))
;;; Per-item generation (mirrors Python generate_table_example_questions / generate_metric_example_questions)
(defn- generate-table-questions
"Generate example questions for a single table/model."
[table]
(let [template (load-template table-template-path)
rendered (selmer/render template
{:table_name (:name table)
:table_description (or (:description table) "")
:columns (map enrich-column (:fields table))})
response (call-llm rendered)]
(validate-questions-response! response (:name table))
{:questions (vec (:questions response))}))
(defn- generate-metric-questions
"Generate example questions for a single metric."
[metric]
(let [template (load-template metric-template-path)
rendered (selmer/render template
{:metric_name (:name metric)
:metric_description (or (:description metric) "")
:dimensions (map enrich-column
(:queryable-dimensions metric))
:default_time_dimension (some-> (:default-time-dimension metric)
enrich-column)})
response (call-llm rendered)]
(validate-questions-response! response (:name metric))
{:questions (vec (:questions response))}))
;;; Batch processing (mirrors Python generate_example_questions_batch with asyncio.gather)
(def ^:private max-batch-size 10)
(defn- process-batch-parallel
"Process items in parallel batches, matching Python's asyncio.gather behavior."
[items generate-fn]
(vec
(mapcat
(fn [batch]
(let [futures (mapv #(future (generate-fn %)) batch)]
(mapv deref futures)))
(partition-all max-batch-size items))))
(defn generate-example-questions
"Generate example questions natively using LLM via OpenRouter.
Accepts the same payload shape as `metabot-v3.client/generate-example-questions`:
{:tables [{:name ... :fields [...]}]
:metrics [{:name ... :queryable-dimensions [...]}]}
Returns the same shape:
{:table_questions [{:questions [...]}]
:metric_questions [{:questions [...]}]}"
[payload]
(log/info "Generating example questions natively"
{:table-count (count (:tables payload))
:metric-count (count (:metrics payload))})
(let [table-questions (if (seq (:tables payload))
(process-batch-parallel (:tables payload) generate-table-questions)
[])
metric-questions (if (seq (:metrics payload))
(process-batch-parallel (:metrics payload) generate-metric-questions)
[])]
(log/info "Native example question generation complete"
{:table-results (count table-questions)
:metric-results (count metric-questions)})
{:table_questions table-questions
:metric_questions metric-questions}))

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