diff --git a/AGENTS.md b/AGENTS.md index 18870e1..9da3c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,7 @@ Current rules: - codecs: `plain`, `lz`, `deflate`, `arx`, `arx2`, and `arx3` - fragment size budget: `8192` characters - decoded payload budget: `200000` characters +- Discord markdown link limit: `2000` characters for the full formatted `[label](url)` string; `createGeneratedArtifactLink*` returns `discordMarkdownLinkWarning` when exceeded - packed wire transport (`p: 1`) is allowed and must decode back to the standard envelope - bundles must contain at least one artifact - artifact ids must be unique within a bundle @@ -118,6 +119,7 @@ If you change the payload contract, update the code, docs, examples, and the Ope - `src/components/renderers/json-renderer.tsx` ### Payload and protocol +- `src/lib/markdown-link.ts` - markdown link formatting and Discord length warnings - `src/lib/payload/schema.ts` - type surface, limits, fragment key, supported kinds/codecs - `src/lib/payload/fragment.ts` - encode/decode logic and transport behavior - `src/lib/payload/arx-codec.ts` - arx/arx2/arx3 codecs: dictionary substitution, tuple overlay, brotli, base76/base1k/baseBMP/base64url encoding diff --git a/docs/payload-format.md b/docs/payload-format.md index 173be54..4faf0df 100644 --- a/docs/payload-format.md +++ b/docs/payload-format.md @@ -102,11 +102,13 @@ Tuple fields: - Supported fragment budget: 8,192 decoded visible fragment characters - Supported decoded payload budget: 200,000 characters +- Discord markdown link limit: 2,000 characters for the full formatted `[label](url)` string - Larger payloads should fail with a clear error before rendering - Compression is selected automatically across packed/non-packed candidates; arx and arx2 optimize conservative transport length, while arx3 optimizes compact visible length for its dense Unicode wire - Default sync codec priority is `deflate -> lz -> plain` - Default async codec priority is `arx3 -> arx2 -> arx -> deflate -> lz -> plain` -- Optional budget-aware encoding can target strict limits like 1,500 chars and returns the shortest fragment when none fit +- Optional budget-aware encoding can target strict limits and returns the shortest fragment when none fit +- `createGeneratedArtifactLink` / `createGeneratedArtifactLinkAsync` return `url`, `markdownLink` (ready to paste verbatim in chat), `markdownLinkLength`, and `discordMarkdownLinkWarning` so agents do not need to reconstruct `[label](url)` themselves When a payload does not fit the fragment budget or the target surface is hostile to long URLs, use UUID mode instead of weakening the fragment protocol. Current UUID mode stores the encoded payload server-side and is not zero-retention. diff --git a/package-lock.json b/package-lock.json index 25c0c40..d73befc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -144,6 +144,7 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -159,6 +160,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -313,7 +315,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -339,7 +340,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -349,7 +349,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -445,7 +444,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -486,7 +484,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2081,7 +2078,6 @@ "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.61.0" }, @@ -2845,6 +2841,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2934,7 +2931,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", @@ -3310,7 +3308,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3321,7 +3318,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3384,7 +3380,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -4040,7 +4035,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4091,6 +4085,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4801,7 +4796,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -5202,7 +5196,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5543,7 +5536,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.2", @@ -5868,7 +5862,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6042,7 +6035,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7586,7 +7578,6 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -9757,6 +9748,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9772,6 +9764,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9784,7 +9777,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -9881,7 +9875,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9891,7 +9884,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11037,7 +11029,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11788,7 +11779,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12034,7 +12024,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12128,7 +12117,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json index db52518..a0ae30e 100644 --- a/public/.well-known/agent-skills/index.json +++ b/public/.well-known/agent-skills/index.json @@ -6,7 +6,7 @@ "type": "skill-md", "description": "Create zero-retention agent-render.com links for markdown, code, diffs, CSV, or JSON artifacts. Markdown artifacts support inline mermaid diagram rendering via fenced code blocks. Use when an agent needs to share a nicely rendered artifact in the browser instead of pasting raw content into chat. Trigger for requests like \"share this as a link\", \"make a diff link\", \"render this markdown/code/csv/json\", \"show this diagram\", or when chat rendering is weak. Agent Render is open source, hosted on Cloudflare Pages, and self-hostable. Use platform-specific linked-text syntax only on surfaces that support it cleanly, such as Discord Markdown links, Telegram HTML links, or Slack mrkdwn links; otherwise send a short summary plus the raw URL.", "url": "https://raw.githubusercontent.com/baanish/agent-render/main/skills/agent-render-linking/SKILL.md", - "digest": "sha256:9bbbf1fbd47fa0781271bfb8cfff6bc6a4b55146ceafab607579d289b683b249" + "digest": "sha256:93826ebd4143754c284e5e6445de33bc2978e01921fd9ef8ce13c5fef028ad29" }, { "name": "selfhosted-agent-render", diff --git a/skills/agent-render-linking/SKILL.md b/skills/agent-render-linking/SKILL.md index d99a662..47912b9 100644 --- a/skills/agent-render-linking/SKILL.md +++ b/skills/agent-render-linking/SKILL.md @@ -173,6 +173,20 @@ Example cases: Set `activeArtifactId` to the artifact that should open first. +## Ready-to-send markdown link + +Do not hand-assemble `[label](url)` when the product already formatted it for you. + +When using the link helpers (`createGeneratedArtifactLink` / `createGeneratedArtifactLinkAsync`), use the returned `markdownLink` string verbatim in chat. It is the exact Discord/Slack-style markdown link the viewer would copy, including label escaping and URL wrapping for special characters. + +The result also includes: +- `markdownLinkLength` — total character count of `markdownLink` +- `discordMarkdownLinkWarning` — non-null when `markdownLink` exceeds Discord's 2,000 character message limit + +If you built the URL yourself and only need the formatted link, call `buildMarkdownLinkShareInfo(label, url)` and send `markdownLink` verbatim. Check `discordWarning` before posting to Discord. + +Only fall back to manual `formatMarkdownLink(label, url)` when you cannot use the helpers above. + ## Link construction Construct the final URL with the compact `#` fragment: @@ -255,7 +269,11 @@ For `arx3`, use the same tuple, substitution, and brotli bytes as arx2, then try Respect these limits: - target fragment budget: about 8,192 decoded visible characters - target decoded payload budget: about 200,000 characters -- strict Discord practical budget for linked text workflows: about 1,500 characters +- Discord message limit for a single markdown link: 2,000 characters total for the formatted `[label](url)` string + +Before sharing on Discord, check `markdownLinkLength` or `discordMarkdownLinkWarning` from the link helpers. If you formatted the link yourself, use `buildMarkdownLinkShareInfo(label, url)` and inspect `discordWarning`. When the warning is non-null, split the bundle into smaller artifacts and send separate markdown links in multiple Discord messages instead of one oversized link. + +When generating links programmatically via `createGeneratedArtifactLink` / `createGeneratedArtifactLinkAsync`, send `markdownLink` verbatim and inspect `discordMarkdownLinkWarning`. When it is non-null, surface that warning to the caller and split the payload before sharing on Discord. If a link is getting too large: 1. try `arx3` first for trusted Unicode-preserving surfaces; otherwise try `arx2`, then `arx`, then `deflate`, then `lz`, then `plain` @@ -280,12 +298,14 @@ Use platform-specific link text only on surfaces that support it cleanly. ### Discord -Prefer standard Markdown links: +Prefer standard Markdown links. When you have `markdownLink` from the link helpers, paste that string verbatim: ```md [Short summary](https://agent-render.com/#) ``` +Check `markdownLinkLength` or `discordMarkdownLinkWarning` before sending. Discord rejects messages longer than 2,000 characters, so a single `[label](url)` string that exceeds that limit will probably fail. When it does, split the artifact into smaller bundles and send multiple markdown links across separate Discord messages. + Examples: - `[Weekly report](https://agent-render.com/#)` - `[Config diff](https://agent-render.com/#)` @@ -331,6 +351,8 @@ When sharing a link: - Prefer `patch` for diffs - Prefer readable titles - Prefer Markdown link text when supported +- Send `markdownLink` verbatim instead of reconstructing `[label](url)` yourself +- Check `discordMarkdownLinkWarning` before sharing markdown links on Discord - Prefer shortest-by-measurement instead of human guesses - Use budget-aware encoding for Discord-like constraints diff --git a/src/app/globals.css b/src/app/globals.css index b7e6335..4c3d819 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -707,7 +707,8 @@ select { .creator-link-frame, .creator-result-note, .creator-empty-state, -.creator-error-state { +.creator-error-state, +.creator-warning-state { border: 1px solid var(--border); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--surface-strong) 92%, transparent); @@ -739,12 +740,31 @@ select { line-height: 1.55; } +.artifact-share-warning { + margin: 0; + padding: 0.85rem 1rem; + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--warning) 8%, var(--surface-strong)); + color: var(--warning); + font-size: 0.86rem; + line-height: 1.55; +} + .creator-error-state { color: var(--danger); font-size: 0.9rem; line-height: 1.55; } +.creator-warning-state { + color: var(--warning); + font-size: 0.9rem; + line-height: 1.55; + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); + background: color-mix(in srgb, var(--warning) 8%, var(--surface-strong)); +} + .artifact-first-layout { display: grid; grid-template-columns: minmax(0, 1fr); diff --git a/src/components/home/link-creator.tsx b/src/components/home/link-creator.tsx index 9574c4e..02c48e8 100644 --- a/src/components/home/link-creator.tsx +++ b/src/components/home/link-creator.tsx @@ -95,15 +95,23 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">( "idle", ); + const [markdownLinkCopyState, setMarkdownLinkCopyState] = useState< + "idle" | "copied" | "failed" + >("idle"); const generationRequestRef = useRef(0); + const markdownCopyTokenRef = useRef(0); + const generatedLinkRef = useRef(null); const isGeneratedLinkStale = Boolean(generatedLink) && draftVersion !== generatedVersion; const contentFieldLabel = getBodyFieldLabel(draft.kind); const GeneratedKindIcon = kindIcons[generatedLink?.artifact.kind ?? draft.kind]; + generatedLinkRef.current = generatedLink; + useEffect(() => { setCopyState("idle"); + setMarkdownLinkCopyState("idle"); setError(null); }, [draftVersion]); @@ -145,6 +153,7 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { setGeneratedVersion(draftVersion); setError(null); setCopyState("idle"); + setMarkdownLinkCopyState("idle"); } catch (generationError) { if (generationRequestRef.current !== requestId) { return; @@ -153,6 +162,7 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { setGeneratedLink(null); setGeneratedVersion(-1); setCopyState("idle"); + setMarkdownLinkCopyState("idle"); setError( generationError instanceof Error ? generationError.message @@ -174,6 +184,35 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { } }; + const handleCopyMarkdownLink = async () => { + const link = generatedLinkRef.current; + if (!link) { + return; + } + + const requestToken = ++markdownCopyTokenRef.current; + const expectedHash = link.hash; + + try { + await copyTextToClipboard(link.markdownLink); + if ( + markdownCopyTokenRef.current !== requestToken || + generatedLinkRef.current?.hash !== expectedHash + ) { + return; + } + setMarkdownLinkCopyState("copied"); + } catch { + if ( + markdownCopyTokenRef.current !== requestToken || + generatedLinkRef.current?.hash !== expectedHash + ) { + return; + } + setMarkdownLinkCopyState("failed"); + } + }; + return (
+
+

Markdown link

+