---
title: Write for SweetValueLab — the submission spec
canonical: https://sweetvaluelab.com/write-for-us
updated: 2026-10-13
---

# Write for SweetValueLab — the submission spec

This file is written to be handed to an AI model. It is everything the model needs
to draft a guest post for https://sweetvaluelab.com/blog that passes the site's automatic checks,
and to submit it through the API on the author's behalf. SweetValueLab provides no
model and runs none on submissions; the author brings their own.

The human-readable version, with the editorial policy in full, is
https://sweetvaluelab.com/write-for-us. The endpoints are described formally in
https://sweetvaluelab.com/openapi.json.

## What gets published

Posts about reading a company or a market from primary data: how a business is
valued from its filings, what a ratio does and does not capture, how a return is
measured, what a backtest can and cannot establish, how to check a claim against
a 10-K. A post earns its place with something a reader can verify: a figure with
its source and date, a worked calculation, a filing named by form and period.

Not published: recommendations, price targets, predictions of where a price is
going, promotional copy, posts written to place links, and posts whose figures
cannot be traced to a source.

The site's own pages are citable sources, linked with site-relative paths: a
covered stock's statements and model values (`/stocks/<ticker>`), managers' 13F
holdings (`/investors`), and the tables behind them as CSV and JSON
(https://sweetvaluelab.com/datasets, CC BY 4.0).

Links to other sites from a guest post, and from an author page, are rendered
with `rel="ugc nofollow"`. They are for sources and further reading; they pass no
search ranking.

## The rules the prose is held to

1. **Describe the data; do not draw the conclusion.** A price against a model is
   "undervalued / overvalued by N% vs the model", with the number and the model
   named. Never a bare "undervalued" in your own voice, and never "buy", "sell", or
   a target price. A third-party rating may be quoted verbatim with its source named.
2. **Every number carries its source and its date.** "Revenue was $X in fiscal
   2025 (10-K filed YYYY-MM-DD)" rather than "revenue is about $X".
3. **Write in one language, English or Chinese.** The site reads which from the
   text and files the post in that edition; you do not need to say.
4. **Plain rhythm.** Most paragraphs end on a fact, not a punchline. These limits
   are measured on every submission and reported as warnings:
   - at most 2 "it's not A, it's B" constructions per post;
   - at most 2 paragraphs that end on a contrast punchline, and never 2 in a row;
   - at most one em dash per 250 words (English) or per 600 characters (Chinese);
   - at least 15% short sentences in English (25% in Chinese).

## The document

A post is ONE Markdown document: a frontmatter block between two `---` lines,
then the body. The frontmatter is `key: value` lines, not general YAML. Lists
are written `[A, B]` on one line.

```markdown
---
title: How a bank's tangible book value is built
description: A walk through one large bank's balance sheet, from total equity to tangible book value per share, with each line traced to the 10-K it was filed in.
date: 2026-09-24
tags: [banks, valuation]
stocks: [JPM]
hero: /blog/img/0123456789abcdef.png
heroAlt: A bar chart of total equity, goodwill and intangibles for one bank
---

Opening paragraph: what the post shows and where the numbers come from.

## First section

Body text. Link to site pages with site-relative paths, e.g.
[the bank board](/tools/banks) or [JPMorgan's page](/stocks/jpm).

![Tangible book value per share, 2016-2025](/blog/img/fedcba9876543210.png)
```

The two `/blog/img/…` paths above are placeholders: real ones come back from the
image upload in step 5 of the API sequence below.

### Fields

Allowed keys: title, description, date, updated, tags, author, stocks, hero, heroAlt. Any other key is an error.

| key | required | rule |
|---|---|---|
| `title` | yes | 60 characters at most in English, 36 in Chinese |
| `description` | yes | 120-160 characters in English, 40-90 in Chinese; one full sentence (it is the index blurb and the search snippet) |
| `date` | yes | `YYYY-MM-DD` (today is fine). The date readers see is the day the post is published |
| `updated` | no | `YYYY-MM-DD`, not before `date` |
| `tags` | no | lowercase words, e.g. `[valuation, banks]` |
| `stocks` | no | UPPERCASE tickers from the covered universe (https://sweetvaluelab.com/stocks); the post is listed on each of those stocks' pages. A ticker we do not cover is an error |
| `hero` | no | the cover image: an uploaded `/blog/img/….png`, `.webp` or `.jpg` (not SVG), 1600×900. Leave it out and an editor may add one in the site's own style |
| `heroAlt` | with `hero` | alt text for the cover; `hero` and `heroAlt` come as a pair |
| `author` | — | ignored: the byline is the account that submits the post |

The slug (the URL's last segment) is derived from the title unless you send one:
lowercase letters, digits and hyphens, no slashes, and not `en` or `zh`.

### Body

- Markdown only. Raw HTML is an error (it would render as visible source).
- Internal links are site-relative (`/stocks/aapl`, `/tools/banks`,
  `/blog/<slug>`). A link to a stock, investor, author or blog post that does not
  exist is an error. External links are written in full with `https://`.
- Every figure needs alt text, and its `src` must be an image uploaded to THIS
  post (`POST /posts/{id}/images`); anything else is an error. So the order is:
  create the draft, upload the images, then put their paths in the body.
- At most 100 kB of Markdown.

## The API

Base URL: `https://api.sweetvaluelab.com`. Every response is one JSON envelope:

```json
{ "ok": true, "schema_version": "1", "as_of": "2026-09-24", "code_version": "…", "data": { }, "error": null }
```

On failure `ok` is false, `data` is null and `error` is
`{ "code": "…", "message": "…", "details": { } }`.

Authentication is a personal API token sent as `Authorization: Bearer svl_…`.
The author mints it once while signed in at https://sweetvaluelab.com/account/tokens (sign up at
https://sweetvaluelab.com/sign-in); it is shown once, does not expire, and stops working
when revoked. Never put it in a URL.

### 1. Check the token

```sh
curl -s https://api.sweetvaluelab.com/account -H "Authorization: Bearer $SVL_TOKEN"
```

`data.byline` is `null` until the account has a byline. A byline is optional:
a post from an account without one is credited "Guest contributor". An agent asks
its author once whether they want one now, and goes on to step 3 if not.

### 2. Set the byline (optional, once per account)

It is shown as soon as it is saved, on every post the account has published, so
it can also be added after a post is live. A pen name is fine. At least one `https` link to a public page that backs the
identity (GitHub, X, LinkedIn, a personal site) is required. `bio` is at most
600 characters and states only facts a reader could check; `penName` is at
most 60. It can be changed at any time the same way.

An agent setting this up for its author asks the author for every field and
sends only what they said, after showing them the request. A byline is printed
under a real person's name; nothing in it may be inferred or embellished.

```sh
curl -s -X PUT https://api.sweetvaluelab.com/profile \
  -H "Authorization: Bearer $SVL_TOKEN" -H "Content-Type: application/json" \
  -d '{"penName":"Jane Analyst","role":"CFA charterholder","bio":"Covered regional banks at a sell-side firm from 2015 to 2021.","links":[{"label":"LinkedIn","url":"https://www.linkedin.com/in/example"}]}'
```

### 3. Dry-run the draft (repeat until it passes)

`?dryRun=1` runs every check, stores nothing and counts against no quota. The
body is JSON: `markdown` (the whole document as one string) and optional
`slug`. Leave `lang` out: the language (`en` or `zh`) is detected from the
text on every save, and an editor corrects it if the guess is wrong.

```sh
jq -n --rawfile md post.md '{markdown: $md}' > body.json
curl -s -X POST "https://api.sweetvaluelab.com/posts?dryRun=1" \
  -H "Authorization: Bearer $SVL_TOKEN" -H "Content-Type: application/json" \
  --data-binary @body.json
```

The answer is `data.precheck`: `{ "ok": boolean, "issues": [...] }`. Each issue has
`severity` (`error` blocks submission, `warn` does not), `message`, and where it
applies `line` (1-based, in the document you sent) and `key` (the frontmatter
field). Fix every `error`; read every `warn`. A figure always fails the dry run,
because nothing has been uploaded yet; leave figures out until step 5.

### 4. Create the draft

The same call without `?dryRun=1`. It returns `data.post.id`. A draft is saved
even with open issues, so it can be fixed in place. At most 10 drafts
per account in 24 hours, and 20 unsubmitted drafts at any time.
Writes are limited to about 30 a minute; a dry-run loop should pause
between attempts.

### 5. Upload images, then replace the document

```sh
curl -s -X POST https://api.sweetvaluelab.com/posts/$ID/images \
  -H "Authorization: Bearer $SVL_TOKEN" -H "Content-Type: image/png" \
  --data-binary @figure.png
```

Returns `data.path` (`/blog/img/<hash>.png`) to use as `hero` or in
`![alt](path)`. PNG, JPEG, WebP or SVG (an SVG with scripts is refused), at most
2 MB each, 20 per post, and 200 images or
50 MB across the whole account. A client that cannot send raw bytes can
send JSON `{"data": "<base64>", "contentType": "image/png"}` instead.

Then send the whole updated document (not a patch):

```sh
curl -s -X PATCH https://api.sweetvaluelab.com/posts/$ID \
  -H "Authorization: Bearer $SVL_TOKEN" -H "Content-Type: application/json" \
  --data-binary @body.json
```

### 6. Submit

```sh
curl -s -X POST https://api.sweetvaluelab.com/posts/$ID/submit -H "Authorization: Bearer $SVL_TOKEN"
```

The checks run again, and a content check reads the post. Then one of two things:

- `data.published` is `true`: the post is live at `data.path`. This is what happens
  whenever the content check passes, for any account, with or without a byline.
- `data.published` is `false`: the post is `in_review` and `data.review.reasons`
  says why — the content check flagged it or could not judge it. An editor then
  publishes it or rejects it with a note, and the author is emailed either way.

`GET /posts/mine` lists the account's posts with their status; `GET /posts/{id}`
returns one with its last check, and `data.markdown` is the whole document,
frontmatter included, ready to edit and send back. A rejected post can be edited
(`PATCH`, which makes it a draft again) and resubmitted.

### 7. Withdraw, delete, or unpublish

```sh
curl -s -X POST   https://api.sweetvaluelab.com/posts/$ID/withdraw  -H "Authorization: Bearer $SVL_TOKEN"
curl -s -X DELETE https://api.sweetvaluelab.com/posts/$ID           -H "Authorization: Bearer $SVL_TOKEN"
curl -s -X POST   https://api.sweetvaluelab.com/posts/$ID/unpublish -H "Authorization: Bearer $SVL_TOKEN"
```

- **withdraw** takes an `in_review` post back to `draft`, so it can be edited;
  submit it again afterwards.
- **delete** removes a `draft` or `rejected` post from the account's posts. It still
  counts towards the daily draft limit.
- **unpublish** takes a `published` post off the site at once and back to `draft`.
  Links to it stop working until it is published again; an open revision of it is
  discarded.

Each answers 409 `not_allowed` from any other state, with the reason.

### 8. Edit a published post

A live post is not edited in place. Open a revision of it:

```sh
curl -s -X POST https://api.sweetvaluelab.com/posts/$ID/revise -H "Authorization: Bearer $SVL_TOKEN"
```

`data.post.id` is the revision — an ordinary draft holding the live text and
its images — and `data.markdown` the document. Change what needs changing,
`PATCH` the revision, then submit it (step 6). When it goes out its text replaces
the live post's; the URL, the first-publication date and the post's id stay. Until
then the page does not change. A post has at most one open revision; calling
`revise` again returns it. A revision keeps the post's slug and language, and
deleting it discards the change.

## Errors

| HTTP | `error.code` | meaning |
|---|---|---|
| 400 | `bad_request` | the JSON is missing `markdown`, `lang` is not `en`/`zh`, or a profile field is invalid |
| 401 | `unauthenticated` | no token, or a revoked or mistyped one |
| 403 | `account_suspended` | an editor has turned writing off for this account |
| 404 | `not_found` | no post with that id on this account |
| 409 | `not_editable` / `already_submitted` | the post is in review, or live (open a revision with `POST /posts/{id}/revise`) |
| 409 | `not_published` | `revise` on a post that is not live; edit it directly |
| 409 | `revision_moves_url` | a revision tried to change the post's slug or language |
| 409 | `not_allowed` | withdraw, delete or unpublish from a state that does not allow it |
| 409 | `slug_taken` | the profile slug belongs to another author |
| 413 | `too_large` | the document is over 100 kB, an image over 2 MB, or the request body over the size the endpoint accepts |
| 422 | `precheck_failed` | the document has errors; the list is in `error.details.issues`, same shape as the dry run |
| 429 | `quota_exceeded` | 10 drafts in 24 hours, 20 unsubmitted drafts, 20 images on one post, or the account's 200 images / 50 MB |
| 429 | `rate_limited` | too many writes in a minute; wait and retry |

The common 422s: a missing `title`, `description` or `date`; a description
outside 120-160 characters; a lowercase or uncovered ticker in `stocks`;
an unknown frontmatter key; a link to a page that does not exist; a figure that
was not uploaded to this post; raw HTML in the body.

## Wording the check flags (warnings, English)

- `\b(?:under|over)valued\b(?!\s+by\s+\d+(?:\.\d+)?\s?%\s+(?:vs\.?|versus|against|relative to)\s+(?:the\s+|our\s+|its\s+)?models?\b)` — a conclusion about the security — give the number and name the model ("undervalued by 18% vs the model"), or say the price is below / above the model value
- `\b(?:price target|fair value target)\b` — recommendation language — name the value a model computes, not a target
- `\b(?:you|investors?)\s+should\s+(?:buy|sell|own|avoid)\b` — no advice to act — state the facts and let the reader decide
- `\b(?:a\s+)?(?:strong\s+)?(?:buy|sell)\s+(?:signal|rating|call|opportunity)\b` — a call, not a fact — quote a third-party rating verbatim and name the source
- `\b(?:guaranteed|risk-?free)\s+(?:return|profit|gain)` — there is no risk-free return — this crosses the YMYL line outright
- `\bwill\s+(?:rise|fall|double|crash|outperform)\b` — no price predictions — write what happened and what the model assumes

A warning does not block submission.

## The terms, in short

- Unpaid, and free to submit. There is no fee in either direction.
- Links to other sites carry `rel="ugc nofollow"`. A post that reads as written
  to place links waits for an editor and is not published as it stands.
- Posts and edits that pass the automatic checks go live on submission, and
  editors read them afterwards: they may edit a post for accuracy, clarity, length
  and house style, or unpublish it. A post the checks hold waits for an editor.
- Submitting grants SweetValueLab permission to publish the post and to edit it.
  The author can unpublish it at any time.
- The byline is optional. Without one a post is credited "Guest contributor"; with
  one it is the pen name on the profile, linked to an author page that lists the
  public links given. The bio states only what can be verified.

Research and education only; nothing on SweetValueLab is investment advice.
