Integrations
TypeScript client
Install one package, generate a types file for your workspace’s exact collections and forms, and your editor autocompletes your own field names; your build fails the moment you rename one out from under the site.
The npm package
@sedgemark/client is a zero-dependency, edge-safe runtime (it runs in
Node, Cloudflare Workers and the browser) plus the sedgemark CLI, which
generates a types-only file describing
your workspace. The types file has no runtime, is safe to commit, and a stale one is a
compile error rather than a bug.
npm i @sedgemark/client
SEDGEMARK_API_KEY=sk_... npx sedgemark generate --base-url https://acme.sedgemark.app
That writes src/sedgemark.types.ts. A key granted collections:read is enough: generating types only reads your schema; add forms:read if you want your forms typed too. Commit the file; in CI, npx sedgemark generate --check exits non-zero when it is stale, so
“added a field, forgot to regenerate” fails the build.
import { createClient } from '@sedgemark/client'
import type { SedgemarkSchema } from './sedgemark.types'
const sm = createClient<SedgemarkSchema>({
baseUrl: 'https://acme.sedgemark.app',
apiKey: process.env.SEDGEMARK_API_KEY, // omit entirely for public collections
})
// Typed as your own collection: collection('blog_pst') is a compile error.
const { data, total } = await sm.collection('blog_post').list({ limit: 20 })
// One entry (the bare row), every entry (pagination handled), a media URL.
const post = await sm.collection('blog_post').get(id)
const everything = await sm.collection('blog_post').listAll()
const cover = post.cover_image ? sm.resolveMedia(post.cover_image) : null Forms with the package
Call sm.form(slug) when the form renders, not inside the submit handler:
that call starts the anti-spam token fetch, and the token must be older than the
form’s min_submit_seconds by the time the submission lands. Form
requests never attach your API key, so this code is safe in a browser.
import { createClient, SedgemarkApiError } from '@sedgemark/client'
const sm = createClient<SedgemarkSchema>({ baseUrl: 'https://acme.sedgemark.app' })
// No apiKey, and never configure one for browser-side form code.
// When the form RENDERS, this starts the anti-spam token fetch, so the
// token is old enough by the time the visitor submits:
const form = sm.form('contact')
// Later, in the submit handler:
try {
const result = await form.submit({ email: 'visitor@example.com', message: 'Hello!' })
// result.id === null means the honeypot was tripped: show the thank-you.
} catch (err) {
if (err instanceof SedgemarkApiError) {
err.errors // [{ field, message }]: every failing field at once
err.code // 'token_invalid' | 'token_expired' | 'submitted_too_fast' | null
}
}
Runtime fixes reach you through npm update; regenerating is only ever about
your schema. The package README covers the full surface: mediaMeta(), per-call fetch options for Next.js caching and AbortSignal, rate-limit behavior, and server-side form proxying.
The no-install alternative: the generated client
Sedgemark can also generate a complete, self-contained runtime client from your current schema: the right choice when adding a dependency is not an option, and the only flow agents can use without npm access. Nothing to install, no version to keep in step. Regenerate it whenever your schema changes.
A dashboard session, or a key granted collections:read.
- From the dashboard: the API Documentation page has a download button.
- From the command line: with a key
granted
collections:read. Form types are emitted only when the key also holdsforms:read, so a key minted purely to read content cannot discover what forms exist. - From an agent: the MCP
get_clienttool returns exactly the same file without a second HTTP request. See MCP server.
curl https://acme.sedgemark.app/api/dashboard/sdk \
-H "Authorization: Bearer $SEDGEMARK_API_KEY" \
-o src/lib/sedgemark-client.ts
The response is the TypeScript source itself, served as text/plain with a suggested filename of sedgemark-client.ts. Check it into your repository and regenerate it when your
collections change: a schema change your code has not caught up with should be a
compile error, which only works if the generated file is versioned alongside the code.
You get a real SedgemarkClient class with working methods, plus helper
functions, not a .d.ts of bare interfaces. Nothing else needs installing
to use it; it depends only on fetch.
Using the generated client
import { SedgemarkClient, fetchAll, resolveMedia, excerpt } from './sedgemark-client'
const client = new SedgemarkClient({
baseUrl: 'https://acme.sedgemark.app',
apiKey: process.env.SEDGEMARK_API_KEY, // omit entirely for public collections
})
// One page. Typed as your own collection, not `any`.
const { data, total } = await client.blog_post.list({ limit: 20, offset: 0 })
// One entry.
const post = await client.blog_post.get(id)
// Every entry, pagination handled for you.
const everything = await fetchAll((opts) => client.blog_post.list(opts))
// A media field id turned into a URL.
const cover = post.cover_image
? resolveMedia(post.cover_image, { baseUrl: 'https://acme.sedgemark.app' })
: null
// A plain-text summary from a rich_text field.
const summary = excerpt(post.body, { maxLength: 160 })
Each collection becomes a property on the client with two methods, list(opts) and get(id), typed to that collection’s
interface. Both wrap the Content API, so the same rules
apply: published entries only, offset pagination, the same status codes.
| Export | What it does |
|---|---|
| SedgemarkClient | The client class. Takes { baseUrl, apiKey? }. |
| SedgemarkApiError | Thrown on any non-2xx response, with the parsed body attached. |
| resolveMedia(id, { baseUrl }) | Builds the media URL for an asset id. |
| fetchAll(list, opts?) | Drains every page of a list method into one array. |
| fetchAllPages(list, opts?) | The same, as an async generator, one page at a time, for collections too large to buffer. |
| excerpt(html, opts?) | Strips tags from a rich_text value and truncates on a word boundary. |
Read-only, and no draft option
The client has no content write methods, because the delivery API has none. Content is written from the dashboard or through the MCP tools. The one exception is form submission, below.
ListOptions is { limit?, offset? } and deliberately has no status. Offering one would hand a caller asking for drafts a page of
published entries with no error at all, worse than not offering it.
Branded ids
Id-bearing fields generate as branded string types rather than plain string: a media field as MediaId, a relation as AuthorId or AuthorId[]. Passing an author id where a media id is
expected is a compile error.
A brand is a subtype of string, so <img src={post.cover_image}> still typechecks; and still renders
nothing. The brands catch mixing one id type up with another; resolving an id to a URL
is on you. Use resolveMedia.
What is left out
Inactive forms and any collection or form your current plan does not cover are omitted from the generated file entirely. A method for one of them would compile and then fail at runtime, which is the worst of both outcomes. If something you expected is missing, check that it is active and covered by your plan, then regenerate.
Errors
import { SedgemarkApiError } from './sedgemark-client'
try {
await client.blog_post.get(id)
} catch (err) {
if (err instanceof SedgemarkApiError) {
err.status // the HTTP status
err.errors // [{ field, message }] on a form validation failure
err.code // 'token_invalid' | 'token_expired' | 'submitted_too_fast' | null
}
throw err
} Forms with the generated client
When your workspace has active forms, the client also gains client.forms.{slug}: the one write it can perform. Requests to it never attach an Authorization header, even if you configured the client with a key, so form
code is safe to run in a browser.
import {
SedgemarkClient,
contactFormFields,
contactHoneypotField,
validateContactPayload,
} from './sedgemark-client'
// No apiKey, and never configure one for browser-side form code.
const client = new SedgemarkClient({ baseUrl: 'https://acme.sedgemark.app' })
const payload = { email: 'visitor@example.com', message: 'Hello!' }
// Optional: catches problems without a round trip. Returns every failing
// field at once, exactly as the server would.
const errors = validateContactPayload(payload)
if (errors.length === 0) {
const result = await client.forms.contact.submit(payload)
// { ok: true, id, submitted_at, ignored?: [...] }
// result.id === null means the honeypot was tripped: show the thank-you.
} The generated validator mirrors the server exactly and is never stricter: it normalizes values the same way before measuring them, so it cannot reject something the API would have accepted. It is a UX affordance, not a security boundary; the server revalidates regardless.
The client also handles the anti-spam token for you: it primes the form descriptor when
the client is constructed, so submit() never mints a token too late to be
valid. Note that submit() deliberately does not validate first: call validate() when you want field errors before the request goes out.
Each form also exports a field spec array, so a generic form component can render itself from your schema instead of hardcoding inputs:
{contactFormFields.map(({ slug, name, required, values, attrs }) => {
const { control, ...inputAttrs } = attrs
return (
<label>
{name}{required && ' *'}
{control === 'textarea' ? (
<textarea name={slug} {...inputAttrs} />
) : control === 'select' ? (
<select name={slug} {...inputAttrs}>
<option value="">Choose…</option>
{values.map((v) => <option value={v}>{v}</option>)}
</select>
) : (
<input name={slug} {...inputAttrs} />
)}
</label>
)
})} attrs uses React’s DOM naming: inputMode, autoComplete, maxLength. In plain HTML the equivalents are
lowercase. Pasting one into the other’s context silently drops the attribute.
Astro
There is no Sedgemark package for Astro, on purpose. A content-layer loader for the
delivery API is about fifteen lines of fetch, and a dependency for that
would be one more thing to keep in step with Astro’s own releases. Copy the loader
below into your project and adapt it; it pages your content into a normal content
collection, so if you have used Astro with local Markdown files this works the same way:
same collections, same getEntry.
// src/content.config.ts
import { defineCollection, z } from 'astro:content'
const SEDGEMARK_BASE_URL = 'https://acme.sedgemark.app'
const API_KEY = import.meta.env.SEDGEMARK_API_KEY // omit entirely for public collections
/** A content-layer loader is ~15 lines of fetch: no extra package needed for it. */
function sedgemarkLoader(collection) {
return {
name: `sedgemark:${collection}`,
load: async ({ store, parseData }) => {
store.clear()
const headers = API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}
// /api/v1 returns { data, total, limit, offset } and has no hasMore field,
// so loop until you have seen `total` rows.
for (let offset = 0; ; ) {
const url = `${SEDGEMARK_BASE_URL}/api/v1/${collection}?limit=200&offset=${offset}`
const res = await fetch(url, { headers })
if (!res.ok) throw new Error(`${collection}: ${res.status}`)
const page = await res.json()
for (const row of page.data) {
store.set({ id: row.id, data: await parseData({ id: row.id, data: row }) })
}
offset += page.data.length
if (page.data.length === 0 || offset >= page.total) break
}
},
}
}
/** A media field holds an asset id. This endpoint 302s to a presigned URL. */
const resolveMedia = (id) => `${SEDGEMARK_BASE_URL}/api/v1/media/${id}`
const blog = defineCollection({
loader: sedgemarkLoader('blog_post'),
schema: z
.object({
title: z.string(),
body: z.string(), // pre-sanitized HTML
cover_image: z.string().nullable(), // a media asset id, not a URL
})
.transform((entry) => ({
...entry,
coverImageUrl: entry.cover_image ? resolveMedia(entry.cover_image) : null,
})),
})
export const collections = { blog } ---
// src/pages/blog/[id].astro
import type { GetStaticPaths } from 'astro'
import { getCollection } from 'astro:content'
// A dynamic route in Astro's default static output needs getStaticPaths.
// Entries are keyed by their Sedgemark entry id.
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await getCollection('blog')
return posts.map((post) => ({ params: { id: post.id }, props: { post } }))
}
const { post } = Astro.props
---
{post.data.coverImageUrl && (
<img src={post.data.coverImageUrl} alt="" />
)}
<Fragment set:html={post.data.body} />
The loader has no schema awareness of its own: your Zod schema is where you
shape fields and resolve media ids, as in the example above. It only ever sees published
entries, which is the right default for a site build, because /api/v1 never
returns drafts.
Nothing above requires living inside content.config.ts. Pulling sedgemarkLoader and resolveMedia out into their own src/sedgemark.ts and importing them from there works the same way and reads
better once you use either one from more than one place on the site.
The response is { data, total, limit, offset }. Loop until you have
seen total rows, as the example does: there is no flag telling you a page
was the last one.
Other frameworks
There is no Next.js, SvelteKit or Nuxt adapter, because none is needed: @sedgemark/client is plain TypeScript over fetch with no
framework assumptions, so it works anywhere those do, including per-call fetch options
for Next.js’s data cache. Or call the HTTP API directly: it is six endpoints in
total, across Content, Media
and Forms.