Embed a URL that stands alone on its line in rich text

Merged
RO
2d ago
+1544-33

An agent that writes a URL on a line by itself in a card, comment, message, document, to-do or schedule entry now gets an embed, the way a person does by pasting the link in the editor: a Dash chart, a YouTube video, anything Basecamp's oEmbed providers cover.

Card: Agents can't add embeds to rich text
Server: https://github.com/basecamp/bc3/pull/13962
SDK: https://github.com/basecamp/basecamp-sdk/pull/1019

The design

A URL alone on its line in Markdown content becomes an embed. No flag, no new syntax. That's the shape agents already write (the card's report was four Dash links, one per line), and it's what the editor does on paste. Writing [title](url) keeps a link, and it's already the form the skill asks for when a link is meant as a link. A URL inside a sentence, a list, a quote, a table, a heading or code is left alone, as is everything sent with --format html.

I considered and rejected:

  • An --embed <url> flag. It's one more thing to learn, and it can't say where in the body the embed goes.
  • Embedding every provider URL anywhere in the text. It would turn a link mentioned in a sentence into a player.

basecamp embeds create <url> and basecamp embeds show <sgid> cover the new SDK methods. They're also how to embed with --format html: create waits for the embed and prints its attachment tag.

How it works

  • richtext.MarkdownToHTMLWithEmbeds parses once and collects the URLs that stand alone on a line of a top-level paragraph. It asks an EmbedFunc for the ones it can embed, splits each paragraph around them into <bc-attachment sgid="…" content-type="…"> blocks, then renders. MarkdownToHTML is that with no EmbedFunc, so its output doesn't change.
  • richTextToHTML passes embedLinks(cmd), so every rich-text command gets this in one place. The EmbedFunc runs under one 10-second context that bounds every embed request. It creates the embeds, then polls the pending ones together once a second, as the editor does, and inserts only the active ones. Rich text that's already rendered is cached, so an embed inserted while pending could stay a link.
  • Nothing fails the post. A URL Basecamp can't embed (422), or a Basecamp without this endpoint (404), stays a link with no warning. A refusal, a 429, a provider with nothing to embed or too slow to answer, or a hung request also stays a link, with a one-line warning on stderr. The first failure that isn't a refusal ends the embed step, so a struggling server costs the post one request, not one per URL.

Attacked

  • Bad URLs: javascript:, ftp:, mail autolinks, a URL with markup, a URL with an escape or a character reference (&amp;), two URLs on a line, and a URL after a <bc-attachment> are never sent. The server refuses non-provider URLs itself.
  • HTML passthrough: --format html makes no embed requests and sends the HTML byte for byte. Raw HTML blocks aren't paragraphs, and a paragraph holding inline HTML anywhere is left whole, since splitting it would break the HTML. EmbedToHTML escapes what it writes.
  • Person versus agent token: the CLI doesn't distinguish them, and the server allows both. A refusal (403) stays a link with a warning.
  • Rate limits: a 429 stays a link with a warning. The server's embed 429 is an hourly verdict with no Retry-After. The SDK models it as not retried, and its hook names the operation CreateEmbed, so isVerdict429 doesn't set the CLI-wide 60-second block that would otherwise hold back the post.
  • The same URL twice is created once and embedded at both places.

Also

  • --format help on every rich-text command says a URL alone on its line becomes an embed.
  • skills/basecamp/SKILL.md documents it with an example (11.4 KB of the 16 KB budget), and skill-evals/cases/create-card-embed.yml checks that an agent asked for an inline chart puts the bare URL on its own line.
  • The MCP server's catalog serves create_embed and get_embed in the files domain, from the vendored model synced at the same SDK commit.
  • TestGatingHooksHeaderless429BlocksUnlessTheOperationTreatsItAsAVerdict now pins that the embed limit's 429 sets no block. It fails against the SDK's first commit, before the fix.
  • Catalog, root, commands_test.go, .surface, e2e/smoke/smoke_embeds.bats and API-COVERAGE.md (209 of 209).

Not done, on purpose

Embed requests share the circuit breaker the post goes through. If the breaker already holds four failures, one failed embed request could open it before the post. The embed step makes at most one failed request per post, which is the same exposure as the mention lookups and image uploads that already run before a post. Isolating it would mean a second SDK client for one optional step.

Depends on

go.mod pins basecamp-sdk at its v0.27.0 release (make bump-sdk REF=v0.27.0, which resolves the go/v0.27.0 module tag), the release that carries basecamp/basecamp-sdk#1019. The MCP model is re-synced from that tag and the Nix vendorHash is updated.

The server side, basecamp/bc3#13962, is merged and has been deployed to production since 2026-10-21.