From 91a10f0ed9b1c415fbb4c7127f20a6f7150499a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 05:46:21 +0000 Subject: [PATCH 1/3] Add Discord markdown link length warnings for agents Expose helpers to detect when a formatted [label](url) exceeds Discord's 2000-character message limit, return discordMarkdownLinkWarning from link generation, and surface the warning in the viewer and link creator UI. Update the agent skill, docs, and agent-skills digest accordingly. Co-authored-by: Aanish Bhirud --- AGENTS.md | 2 + docs/payload-format.md | 4 +- package-lock.json | 36 ++++++------------ public/.well-known/agent-skills/index.json | 2 +- skills/agent-render-linking/SKILL.md | 9 ++++- src/app/globals.css | 22 ++++++++++- src/components/home/link-creator.tsx | 12 ++++++ src/components/viewer/artifact-stage.tsx | 31 ++++++++++++++-- src/lib/markdown-link.ts | 43 ++++++++++++++++++++++ src/lib/payload/link-creator.ts | 19 ++++++++++ tests/components/link-creator.test.tsx | 9 ++++- tests/link-creator.test.ts | 26 +++++++++++++ tests/markdown-link.test.ts | 25 ++++++++++++- 13 files changed, 207 insertions(+), 33 deletions(-) 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..13728a7 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 `markdownLink`, `markdownLinkLength`, and `discordMarkdownLinkWarning` so agents can detect Discord-unsafe markdown links before sharing 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..6d74804 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:1472351a9393725a922c453952f2ddffc227e1f1c1273d0b582ee1f2c484e978" }, { "name": "selfhosted-agent-render", diff --git a/skills/agent-render-linking/SKILL.md b/skills/agent-render-linking/SKILL.md index d99a662..0b06ce4 100644 --- a/skills/agent-render-linking/SKILL.md +++ b/skills/agent-render-linking/SKILL.md @@ -255,7 +255,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, format the link with `formatMarkdownLink(label, url)` (or the equivalent in your language) and check the total character count. If it exceeds 2,000 characters, the message will probably break on Discord. 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`, inspect `discordMarkdownLinkWarning` on the result. 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` @@ -286,6 +290,8 @@ Prefer standard Markdown links: [Short summary](https://agent-render.com/#) ``` +Check the total formatted markdown link length 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 +337,7 @@ When sharing a link: - Prefer `patch` for diffs - Prefer readable titles - Prefer Markdown link text when supported +- 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..ad4d94d 100644 --- a/src/components/home/link-creator.tsx +++ b/src/components/home/link-creator.tsx @@ -372,8 +372,20 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { {numberFormatter.format(generatedLink.fragmentLength)} chars

+
+

Markdown link

+

+ {numberFormatter.format(generatedLink.markdownLinkLength)} chars +

+
+ {generatedLink.discordMarkdownLinkWarning ? ( +
+ {generatedLink.discordMarkdownLinkWarning} +
+ ) : null} +
+ {markdownLinkShareInfo?.discordWarning ? ( +

+ {markdownLinkShareInfo.discordWarning} +

+ ) : null} + {envelope.artifacts.length > 1 ? (
DISCORD_MESSAGE_MAX_LENGTH; +} + +/** + * Builds a Discord warning when a markdown link is too long for a single message. + */ +export function getDiscordMarkdownLinkWarning(markdownLink: string): string | null { + if (!isDiscordMarkdownLinkTooLong(markdownLink)) { + return null; + } + + const formattedLength = markdownLink.length.toLocaleString("en-US"); + const limit = DISCORD_MESSAGE_MAX_LENGTH.toLocaleString("en-US"); + + return `This markdown link is ${formattedLength} characters, which exceeds Discord's ${limit} character message limit. Split the bundle into smaller artifacts and send separate markdown links in multiple Discord messages.`; +} + +/** + * Formats a markdown link and returns share metadata, including a Discord warning when needed. + */ +export function buildMarkdownLinkShareInfo(label: string, href: string): MarkdownLinkShareInfo { + const markdownLink = formatMarkdownLink(label, href); + + return { + markdownLink, + length: markdownLink.length, + discordWarning: getDiscordMarkdownLinkWarning(markdownLink), + }; +} diff --git a/src/lib/payload/link-creator.ts b/src/lib/payload/link-creator.ts index 9c5698d..d123a7f 100644 --- a/src/lib/payload/link-creator.ts +++ b/src/lib/payload/link-creator.ts @@ -1,5 +1,6 @@ import { normalizeEnvelope } from "@/lib/payload/envelope"; import { encodeEnvelope, encodeEnvelopeAsync, getVisibleFragmentLength } from "@/lib/payload/fragment"; +import { buildMarkdownLinkShareInfo } from "@/lib/markdown-link"; import { codecForCompactTag, codecs, @@ -29,6 +30,9 @@ export type GeneratedArtifactLink = { hash: string; url: string; fragmentLength: number; + markdownLink: string; + markdownLinkLength: number; + discordMarkdownLinkWarning: string | null; }; const NON_WHITESPACE_PATTERN = /\S/; @@ -114,6 +118,11 @@ function getFragmentCodec(fragmentBody: string): PayloadCodec { return codecForCompactTag(fragmentBody.charAt(0)) ?? "plain"; } +function buildGeneratedLinkShareInfo(envelope: PayloadEnvelope, url: string) { + const label = envelope.title ?? envelope.artifacts[0]?.title ?? envelope.artifacts[0]?.id ?? url; + return buildMarkdownLinkShareInfo(label, url); +} + /** * Builds a single-artifact payload envelope from link-creator draft input. * @@ -169,6 +178,8 @@ export function createGeneratedArtifactLink(draft: LinkCreatorDraft, baseUrl?: s url = nextUrl.toString(); } + const shareInfo = buildGeneratedLinkShareInfo(normalized.envelope, url); + return { envelope: normalized.envelope, artifact: normalized.envelope.artifacts[0], @@ -176,6 +187,9 @@ export function createGeneratedArtifactLink(draft: LinkCreatorDraft, baseUrl?: s hash, url, fragmentLength, + markdownLink: shareInfo.markdownLink, + markdownLinkLength: shareInfo.length, + discordMarkdownLinkWarning: shareInfo.discordWarning, }; } @@ -212,6 +226,8 @@ export async function createGeneratedArtifactLinkAsync(draft: LinkCreatorDraft, url = nextUrl.toString(); } + const shareInfo = buildGeneratedLinkShareInfo(normalized.envelope, url); + return { envelope: normalized.envelope, artifact: normalized.envelope.artifacts[0], @@ -219,5 +235,8 @@ export async function createGeneratedArtifactLinkAsync(draft: LinkCreatorDraft, hash, url, fragmentLength, + markdownLink: shareInfo.markdownLink, + markdownLinkLength: shareInfo.length, + discordMarkdownLinkWarning: shareInfo.discordWarning, }; } diff --git a/tests/components/link-creator.test.tsx b/tests/components/link-creator.test.tsx index 4fc8d94..d28411d 100644 --- a/tests/components/link-creator.test.tsx +++ b/tests/components/link-creator.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { LinkCreator } from "@/components/home/link-creator"; +import { buildMarkdownLinkShareInfo } from "@/lib/markdown-link"; import type { GeneratedArtifactLink, LinkCreatorDraft } from "@/lib/payload/link-creator"; type PendingGeneration = { @@ -23,6 +24,9 @@ vi.mock("@/lib/payload/link-creator", () => ({ })); function createGeneratedLink(title: string): GeneratedArtifactLink { + const url = `https://agent-render.test/#agent-render=v1.plain.${title}`; + const shareInfo = buildMarkdownLinkShareInfo(title, url); + return { artifact: { id: title.toLowerCase().replace(/\s+/g, "-"), @@ -41,7 +45,10 @@ function createGeneratedLink(title: string): GeneratedArtifactLink { }, fragmentLength: 64, hash: `#agent-render=v1.plain.${title}`, - url: `https://agent-render.test/#agent-render=v1.plain.${title}`, + url, + markdownLink: shareInfo.markdownLink, + markdownLinkLength: shareInfo.length, + discordMarkdownLinkWarning: shareInfo.discordWarning, }; } diff --git a/tests/link-creator.test.ts b/tests/link-creator.test.ts index 9915e89..86c3862 100644 --- a/tests/link-creator.test.ts +++ b/tests/link-creator.test.ts @@ -5,6 +5,7 @@ import arxDictionaryJson from "../public/arx-dictionary.json"; import { loadArx2OverlayDictionarySync, loadArxDictionarySync } from "@/lib/payload/arx-codec"; import { decodeFragment, decodeFragmentAsync } from "@/lib/payload/fragment"; import { createDraftEnvelope, createGeneratedArtifactLink, createGeneratedArtifactLinkAsync, type LinkCreatorDraft } from "@/lib/payload/link-creator"; +import { DISCORD_MESSAGE_MAX_LENGTH } from "@/lib/markdown-link"; import { compactTagForCodec } from "@/lib/payload/schema"; describe("link creator payloads", () => { @@ -63,6 +64,9 @@ describe("link creator payloads", () => { language: "tsx", content: "export function ViewerShell() {\n return
;\n}", }); + expect(generatedLink.markdownLink).toContain("[Viewer shell]"); + expect(generatedLink.markdownLinkLength).toBe(generatedLink.markdownLink.length); + expect(generatedLink.discordMarkdownLinkWarning).toBeNull(); }); it("keeps diff view settings in generated links", () => { @@ -148,4 +152,26 @@ describe("link creator payloads", () => { }), ).toThrow(/paste some content/i); }); + + it("surfaces a Discord markdown link warning when the formatted link is too long", async () => { + const longContent = Array.from({ length: 2800 }, (_, index) => + String.fromCharCode(33 + (index % 94)), + ).join(""); + const generatedLink = await createGeneratedArtifactLinkAsync( + { + kind: "markdown", + title: "Long report", + filename: "long-report.md", + content: `# Report\n\n${longContent}`, + language: "", + diffView: "unified", + codec: "plain", + }, + "https://agent-render.com/", + ); + + expect(generatedLink.markdownLinkLength).toBeGreaterThan(DISCORD_MESSAGE_MAX_LENGTH); + expect(generatedLink.discordMarkdownLinkWarning).toMatch(/Discord's 2,000 character message limit/i); + expect(generatedLink.discordMarkdownLinkWarning).toMatch(/multiple Discord messages/i); + }); }); diff --git a/tests/markdown-link.test.ts b/tests/markdown-link.test.ts index 21b31c8..b7d9511 100644 --- a/tests/markdown-link.test.ts +++ b/tests/markdown-link.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { formatMarkdownLink } from "@/lib/markdown-link"; +import { + buildMarkdownLinkShareInfo, + DISCORD_MESSAGE_MAX_LENGTH, + formatMarkdownLink, + getDiscordMarkdownLinkWarning, + isDiscordMarkdownLinkTooLong, +} from "@/lib/markdown-link"; describe("formatMarkdownLink", () => { it("formats a standard inline link", () => { @@ -27,4 +33,21 @@ describe("formatMarkdownLink", () => { const href = "https://example.com/path?x=a)b>c"; expect(formatMarkdownLink("Wrapped", href)).toBe("[Wrapped]()"); }); + + it("flags markdown links that exceed Discord's message limit", () => { + const href = `https://example.com/#${"a".repeat(DISCORD_MESSAGE_MAX_LENGTH)}`; + const markdownLink = formatMarkdownLink("Report", href); + + expect(isDiscordMarkdownLinkTooLong(markdownLink)).toBe(true); + expect(getDiscordMarkdownLinkWarning(markdownLink)).toMatch(/Discord's 2,000 character message limit/i); + expect(getDiscordMarkdownLinkWarning(markdownLink)).toMatch(/Split the bundle into smaller artifacts/i); + }); + + it("returns no Discord warning for links within the limit", () => { + const shareInfo = buildMarkdownLinkShareInfo("Weekly report", "https://agent-render.com/#pabc"); + + expect(shareInfo.length).toBeLessThanOrEqual(DISCORD_MESSAGE_MAX_LENGTH); + expect(shareInfo.discordWarning).toBeNull(); + expect(shareInfo.markdownLink).toBe("[Weekly report](https://agent-render.com/#pabc)"); + }); }); From 4c17ffe31db1f4ffc9039fc4b9a56c2338546559 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 05:50:57 +0000 Subject: [PATCH 2/3] Document verbatim markdownLink for agents and add copy UI Clarify in the agent skill that createGeneratedArtifactLink* returns a ready-to-paste markdownLink string. Expose it in the link creator with a dedicated copy action. Co-authored-by: Aanish Bhirud --- docs/payload-format.md | 2 +- public/.well-known/agent-skills/index.json | 2 +- skills/agent-render-linking/SKILL.md | 23 ++++++++-- src/components/home/link-creator.tsx | 49 ++++++++++++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/payload-format.md b/docs/payload-format.md index 13728a7..4faf0df 100644 --- a/docs/payload-format.md +++ b/docs/payload-format.md @@ -108,7 +108,7 @@ Tuple fields: - 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 and returns the shortest fragment when none fit -- `createGeneratedArtifactLink` / `createGeneratedArtifactLinkAsync` return `markdownLink`, `markdownLinkLength`, and `discordMarkdownLinkWarning` so agents can detect Discord-unsafe markdown links before sharing +- `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/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json index 6d74804..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:1472351a9393725a922c453952f2ddffc227e1f1c1273d0b582ee1f2c484e978" + "digest": "sha256:93826ebd4143754c284e5e6445de33bc2978e01921fd9ef8ce13c5fef028ad29" }, { "name": "selfhosted-agent-render", diff --git a/skills/agent-render-linking/SKILL.md b/skills/agent-render-linking/SKILL.md index 0b06ce4..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: @@ -257,9 +271,9 @@ Respect these limits: - target decoded payload budget: about 200,000 characters - Discord message limit for a single markdown link: 2,000 characters total for the formatted `[label](url)` string -Before sharing on Discord, format the link with `formatMarkdownLink(label, url)` (or the equivalent in your language) and check the total character count. If it exceeds 2,000 characters, the message will probably break on Discord. Split the bundle into smaller artifacts and send separate markdown links in multiple Discord messages instead of one oversized link. +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`, inspect `discordMarkdownLinkWarning` on the result. When it is non-null, surface that warning to the caller and split the payload before sharing on Discord. +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` @@ -284,13 +298,13 @@ 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 the total formatted markdown link length 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. +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/#)` @@ -337,6 +351,7 @@ 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/components/home/link-creator.tsx b/src/components/home/link-creator.tsx index ad4d94d..4d7aa38 100644 --- a/src/components/home/link-creator.tsx +++ b/src/components/home/link-creator.tsx @@ -95,6 +95,9 @@ 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 isGeneratedLinkStale = Boolean(generatedLink) && draftVersion !== generatedVersion; @@ -104,6 +107,7 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { useEffect(() => { setCopyState("idle"); + setMarkdownLinkCopyState("idle"); setError(null); }, [draftVersion]); @@ -145,6 +149,7 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { setGeneratedVersion(draftVersion); setError(null); setCopyState("idle"); + setMarkdownLinkCopyState("idle"); } catch (generationError) { if (generationRequestRef.current !== requestId) { return; @@ -153,6 +158,7 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { setGeneratedLink(null); setGeneratedVersion(-1); setCopyState("idle"); + setMarkdownLinkCopyState("idle"); setError( generationError instanceof Error ? generationError.message @@ -174,6 +180,19 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { } }; + const handleCopyMarkdownLink = async () => { + if (!generatedLink) { + return; + } + + try { + await copyTextToClipboard(generatedLink.markdownLink); + setMarkdownLinkCopyState("copied"); + } catch { + setMarkdownLinkCopyState("failed"); + } + }; + return (
+
+

Markdown link

+