Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Add a new Seer embed widget — a rich component rendered inline in Seer's markdown output via tag syntax. Covers schema, component, registration, and backend codegen. Use when asked to "add an embed", "new seer embed", "create a seer widget", "add a markdown widget", "new seer tag", or "embed widget".
.claude/skills/getsentry-seer-embed/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 54% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 24% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 113% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 161% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 91% | 0% |
Seer embeds are rich widgets rendered inline in Seer's markdown output using Markdoc-style tag syntax ({% name %}{ ... }{% /name %}). Each embed has a Zod schema, a React component, and a registry entry.
static/app/components/seer/markdown/embeds/schemas.ts to see existing schemas.static/app/components/seer/markdown/embeds/index.ts to see registered embeds.In static/app/components/seer/markdown/embeds/schemas.ts, add an entry to SEER_EMBED_SCHEMAS:
tsexport const SEER_EMBED_SCHEMAS = { // ...existing entries myEmbed: { description: "One sentence describing what this embed does—this passes through directly to the LLM's system prompt.", level: ['inline'], // 'inline', 'block', or both schema: z.object({ // Define the data shape the LLM will produce someField: z.string(), optionalField: z.number().optional(), }), examples: [{label: 'Basic', data: {someField: 'hello'}}], // featureFlag: 'organizations:seer-explorer-my-embed', // optional }, } as const satisfies Record<string, SeerEmbedSchema>;
Key decisions:
description: Write for the LLM — it uses this to decide when to emit the embed. Be specific about the use case.level: Use ['inline'] for widgets that flow within text (timestamps, badges). Use ['block'] for widgets that need their own line (cards, charts). Use both if the embed adapts.schema: Use Zod. Keep it flat and simple — the LLM has to produce valid JSON. Use .default() for optional fields with sensible defaults. Use .enum() to constrain string values.examples: An array of {label, data, level?} objects. Each data must be valid against the schema. label and data go into the generated JSON as few-shot examples for the LLM; level does not — codegen strips it, so it only ever affects the stories page. Use multiple examples to show different prop combinations, not to show inline vs block: on the stories page each example renders in its own demo, and one demo already shows the tag at every level the schema declares (inline wrapped in prose, block on its own line). Set level on an example only when it differs from the schema's default (the first entry in level) — the shared <EmbedStory> fallback treats a level as a signal to relabel the example to the embed's name and drop any later example with identical data, so a redundant level can collapse several examples into same-named ones. Give each example distinct data.featureFlag: Set this to gate the embed behind a feature flag. The backend filters it out of the schema sent to the LLM when the flag is off.Create static/app/components/seer/markdown/embeds/components/<name>.tsx:
tsximport {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; export const MyEmbed = defineSeerEmbed({ name: 'myEmbed', // must match the key in SEER_EMBED_SCHEMAS render({someField, optionalField}) { // Props are typed from the Zod schema — already validated return <span>{someField}</span>; }, });
What defineSeerEmbed does for you:
safeParses the data prop against itnull for invalid data (logs a warning in dev)displayName on the component (used by the registry)Rules:
name parameter must match the key in SEER_EMBED_SCHEMAS exactly.render function receives the Zod output type as its first argument — props are already parsed and validated.level includes both 'inline' and 'block', render gets a second argument telling it which one is rendering. Use it to branch: see Step 2b for the pattern once that branch has real content on the block side.DateTime, TimeSince, Link, etc.) rather than building from scratch.A link-only embed stays a single file. Once an embed renders a block preview -- it fetches data, lazy-loads heavy views, or branches on a subtype -- give it a directory instead, so a reviewer reads one concern at a time:
components/monitor/
monitor.tsx # defineSeerEmbed only: inline link vs lazily imported block
monitorLink.tsx # the inline level
monitorBlock.tsx # default export: fetch, card chrome, dispatch
monitorTypes/ # one file per subtype, when the embed has subtypes
cron.tsx
uptime.tsx
monitor.spec.tsx # colocated, not in a spec shared by every embedThe <name>.tsx entry does nothing but pick which level to render, using the second argument to render from Step 2:
tsxconst LazyMonitorBlock = lazy(() => import('./monitorBlock')); export const Monitor = defineSeerEmbed({ name: 'monitor', render(props, level) { if (level === 'block') { return <LazyLoad LazyComponent={LazyMonitorBlock} {...props} />; } return <MonitorLink {...props} />; }, });
Rules:
<SeerEmbedBlock> from<name>Block.tsx and let it own the chrome -- see "Block chrome" below.
index.tsx. Name the entry after the embed(monitor/monitor.tsx) and import it explicitly in embeds/index.ts.
<name>.tsx holds only defineSeerEmbed, dispatching on level as above.Everything the block needs goes behind lazy(() => import('./<name>Block')), with the block as a default export (what lazy() expects), so an inline mention of the resource does not pull the block into the bundle. dashboard and monitor both follow this.
branch is one file in a sibling directory named for the axis it varies on (monitorTypes/, not types/, which reads as TypeScript types), and the dispatcher is a single switch in the block. Adding a subtype should be a new file plus a case, never an edit to the two switches spread across one long module that this convention replaces.
than re-deriving them inside each variant — re-derivation inside each subtype file is what made the switches in the old monolith hard to keep in sync.
<name>.spec.tsx and use the shared renderEmbed /getEmbedLinkHref helpers from embeds/components/resourceEmbedTestUtils.tsx. Do not add cases to a spec shared by every embed -- one shared file conflicts constantly once block embeds start adding cases to it.
A block embed renders inside SeerEmbedBlock (embeds/components/seerEmbedBlock.tsx), which draws the card every block shares: the resource's name and a collapse toggle on the left of a header band, a View <resource> link on the right, and the embed's preview in a collapsible panel below.
tsxexport default function MonitorBlock({id, name}: EmbedOutput<'monitor'>) { const organization = useOrganization(); return ( <SeerEmbedBlock badge={<Tag variant="muted">{t('Cron')}</Tag>} href={makeMonitorDetailsPathname(organization.slug, id)} icon={IconTimer} linkLabel={t('View Monitor')} testId="seer-monitor-embed" title={name ?? t('Monitor %s', id)} > {/* the preview */} </SeerEmbedBlock> ); }
Rules:
title is the resource's own name; linkLabel is a fixed call to actionnaming the destination (View Dashboard, View Query). The name labels the collapse toggle and is deliberately not a link -- aiming at the title must not navigate out of the conversation.
QueryEmbedCard instead, which is SeerEmbedBlockplus the formatted-query row; it takes the same title/href/icon/ linkLabel props.
Container. Needing chrome the sharedcard cannot express means adding a slot to SeerEmbedBlock, not a second card.
badge sits between the title and the link, for tags describing the contents(a query mode, an enabled/disabled state, a widget count).
Export a get<X>Href / get<X>Title helper from <name>Link.tsx and call it from both.
defaultExpanded={false} ships a block collapsed, for a preview that is tallor slow to load.
In static/app/components/seer/markdown/embeds/index.ts, import and add it to the embeds array:
tsimport {MyEmbed} from './components/myEmbed'; import {Timestamp} from './components/timestamp'; import {SeerEmbedRegistry} from './registry'; const embeds = [Timestamp, MyEmbed]; for (const embed of embeds) { SeerEmbedRegistry.register(embed.displayName, embed); }
Registration uses displayName (set by defineSeerEmbed) as the registry key.
Run the codegen script to update the JSON Schema file the backend sends to the Seer agent:
bashpnpm gen:embed-widgets
This writes to src/sentry/seer/agent/embed_widgets.generated.json. Commit this generated file — it's checked in, not gitignored.
Every embed gets a section in static/app/components/seer/markdown/seerMarkdown.mdx, in the same order as the schema:
mdx### myEmbed <EmbedStory name="myEmbed" />
<EmbedStory name> renders the schema's own examples. That is enough only for an embed that renders purely from its tag body — a timestamp, a badge, a link built from props.
An embed that fetches by ID needs its own story instead. The IDs in examples are invented for the LLM prompt, so nothing resolves them: the block renders its error state and the stories page documents nothing. Write __stories__/<name>EmbedStory.tsx, query the viewer's own organization for a real resource, and feed its ID to EmbedVariant:
tsxexport function MyEmbedStory() { const {data, isError, isPending} = useQuery(/* a list endpoint, limit 1 */); const resource = data?.[0]; return ( <EmbedStory name="myEmbed"> {isPending ? ( <LoadingIndicator /> ) : isError ? ( <Text variant="muted">Unable to load a my-embed example.</Text> ) : resource ? ( <EmbedVariant name="myEmbed" label="My embed" data={{id: resource.id}} /> ) : ( <Text variant="muted">No my-embed is available for this organization.</Text> )} </EmbedStory> ); }
Then import it in the .mdx and use <MyEmbedStory /> in place of <EmbedStory name="myEmbed" />. replayEmbedStory.tsx and savedQueryEmbedStory.tsx are the smallest examples; alertEmbedStory.tsx shows chaining one query into another.
Rules:
EmbedVariant renders every level the schema declares — formatVariantmaps over schema.level — so vary variants by prop combination, not by level.
against whatever organization the viewer is in, and an org with no replays or no saved queries must not render a broken page.
<name>EmbedStory.spec.tsx when the story does non-obviousselection (picking the first resource that satisfies a condition, chaining queries). Stub SeerMarkdown to echo its raw prop and assert on the data the story chose rather than on the embed's own rendering, which its colocated spec already covers.
pnpm run lint:js on your new files.pnpm run typecheck to confirm the schema types flow through.tsx<SeerMarkdown raw={`{% myEmbed %}{"someField":"hello"}{% /myEmbed %}`} />
| File | What to do | | -------------------------------------------------------------------------- | ----------------------------------------------- | | static/app/components/seer/markdown/embeds/schemas.ts | Add Zod schema entry | | static/app/components/seer/markdown/embeds/components/<name>.tsx | Create component with defineSeerEmbed | | static/app/components/seer/markdown/embeds/components/<name>/ | Use a directory instead once it renders a block | | static/app/components/seer/markdown/embeds/components/seerEmbedBlock.tsx | The card chrome every block renders inside | | static/app/components/seer/markdown/embeds/index.ts | Import and register | | static/app/components/seer/markdown/seerMarkdown.mdx | Add a section for the embed | | static/app/components/seer/markdown/__stories__/<name>EmbedStory.tsx | Add one if the embed fetches by ID | | src/sentry/seer/agent/embed_widgets.generated.json | Regenerated by pnpm gen:embed-widgets |
If the embed should be gated:
featureFlag: 'organizations:seer-explorer-<name>' to the schema entry.src/sentry/features/temporary.py.src/sentry/seer/agent/embed_widgets.py) automatically filters flagged embeds using features.has().| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 29,892 | 22,534 | -25% | 1 | 1 | 0% | 4,776 | 7,372 | +54% | 0 | 0 | — |
case-02 | fail→pass | 31,502 | 20,313 | -36% | 1 | 1 | 0% | 5,101 | 6,332 | +24% | 0 | 0 | — |
case-03 | fail→pass | 20,114 | 11,049 | -45% | 1 | 1 | 0% | 2,164 | 4,614 | +113% | 0 | 0 | — |
case-04 | fail→pass | 15,479 | 9,349 | -40% | 1 | 1 | 0% | 1,646 | 4,301 | +161% | 0 | 0 | — |
case-05 | fail→pass | 22,566 | 13,206 | -41% | 1 | 1 | 0% | 2,705 | 5,176 | +91% | 0 | 0 | — |
case-06 | fail→pass | 19,297 | 14,746 | -24% | 1 | 1 | 0% | 2,542 | 5,224 | +106% | 0 | 0 | — |
case-07 | fail→pass | 15,671 | 9,594 | -39% | 1 | 1 | 0% | 1,740 | 4,311 | +148% | 0 | 0 | — |
case-08 | fail→pass | 16,640 | 9,998 | -40% | 1 | 1 | 0% | 1,673 | 4,366 | +161% | 0 | 0 | — |
case-09 | fail→pass | 17,735 | 6,782 | -62% | 1 | 1 | 0% | 2,093 | 4,580 | +119% | 0 | 0 | — |
case-10 | fail→pass | 15,011 | 14,979 | -0% | 1 | 1 | 0% | 2,409 | 5,165 | +114% | 0 | 0 | — |
case-11 | fail→pass | 12,521 | 4,967 | -60% | 1 | 1 | 0% | 2,011 | 4,237 | +111% | 0 | 0 | — |
case-12 | fail→pass | 12,590 | 4,564 | -64% | 1 | 1 | 0% | 2,042 | 4,305 | +111% | 0 | 0 | — |
case-13 | fail→pass | 14,265 | 2,524 | -82% | 1 | 1 | 0% | 2,507 | 3,876 | +55% | 0 | 0 | — |
case-14 | fail→pass | 17,918 | 12,576 | -30% | 1 | 1 | 0% | 2,859 | 5,217 | +82% | 0 | 0 | — |
case-15 | fail→pass | 15,034 | 4,945 | -67% | 1 | 1 | 0% | 2,029 | 4,314 | +113% | 0 | 0 | — |
case-16 | fail→pass | 13,632 | 5,999 | -56% | 1 | 1 | 0% | 2,167 | 4,446 | +105% | 0 | 0 | — |
case-17 | pass→pass | 13,623 | 8,770 | -36% | 1 | 1 | 0% | 1,805 | 4,910 | +172% | 0 | 0 | — |
case-18 | fail→pass | 10,411 | 4,489 | -57% | 1 | 1 | 0% | 1,563 | 4,380 | +180% | 0 | 0 | — |
case-19 | fail→pass | 13,538 | 4,296 | -68% | 1 | 1 | 0% | 1,819 | 4,234 | +133% | 0 | 0 | — |
case-20 | pass→pass | 14,127 | 12,440 | -12% | 1 | 1 | 0% | 2,272 | 5,693 | +151% | 0 | 0 | — |
case-21 | pass→pass | 9,248 | 10,201 | +10% | 1 | 1 | 0% | 1,639 | 5,320 | +225% | 0 | 0 | — |
case-22 | pass→pass | 13,984 | 14,414 | +3% | 1 | 1 | 0% | 2,441 | 6,438 | +164% | 0 | 0 | — |
case-23 | pass→pass | 11,759 | 5,380 | -54% | 1 | 1 | 0% | 1,773 | 4,326 | +144% | 0 | 0 | — |
case-24 | pass→pass | 12,359 | 4,102 | -67% | 1 | 1 | 0% | 1,681 | 4,163 | +148% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +75 percentage points is the difference between those two pass rates over the 24 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
| Model | Method | Date | Lift |
|---|---|---|---|
| gemini-3.6-flash | verified | 9/6/2026 | +64% |
| gemini-3.6-flash | verified | 8/2/2026 | +59% |
Other measured skills in the registry, with their headline benchmark lift.