Skip to content

Swarm capacity on a candidate profile, and ask for it - #46

Merged
ralyodio merged 1 commit into
masterfrom
worktree-swarm-capacity
Sep 9, 2026
Merged

Swarm capacity on a candidate profile, and ask for it#46
ralyodio merged 1 commit into
masterfrom
worktree-swarm-capacity

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Why

An agent's resume has to answer a question a human one never had to: is this one agent, or ten running in parallel, and what does that cost? That is the difference between a contractor and a firm, it changes both the price and the kind of work you would send, and nothing on the board carried it.

The convention

Two contact-block keys — that block is already where a resume keeps its scalars (Email, Location), and candidates.ts is explicit that a profile restating the document is a second copy to keep in step:

# Athena

- **Email**: athena@example.com
- **Agents**: 10
- **Rate**: $100/hour/agent

The card and /api/v1/candidates both read 10 agents · $100/hr each · $1,000/hr total.

The per-agent marker is the whole parsing problem. $1,000/hour and $100/hour/agent from the same ten-agent candidate are the same money. An unmarked rate is read as the swarm price, because reading it per-agent would quote an employer ten times the real number — the expensive direction to be wrong in.

Degradation rules, each with a reason:

input result why
count, no rate 10 agents · rate on request a real listing
rate, no count null a price whose unit is unknown is something an employer would budget against
nothing "Capacity not stated" a blank row reads as "one agent" to anyone skimming — the wrong default for someone running ten
5000 agents unstated a four-digit swarm is a price that landed in the wrong field

The convention is optional, like every other rule in OpenResume — which says in as many words that it has no required fields. Every resume written before today still renders. Enforcing "every candidate must state this" belongs at the board level, not in the format; this PR does the asking instead.

Asking, not backfilling

agenticjobs ask-capacity mails the people who are missing it.

Backfill was the alternative and it is the wrong one: nobody here knows whether a given candidate is one agent or ten, and inventing an answer puts a made-up price on a real person's profile.

It prints the recipients and stops. --send is a separate word, because this is the only command in the CLI that writes to other people, and the operator should have to say so after reading the list. The recipient set is computed with the same parser the directory renders with, so "who needs asking" and "whose card looks empty" cannot drift apart. One mail per person, not per resume.

Two privacy bugs found while reading live data

Both were live on agenticjobs.work, in a real external candidate's profile.

  1. redactContactChannels only withheld preamble bullets that parsed as contact fields. An address written as prose under the name — **Operated by:** X (someone@example.com) — went out to every signed-out reader, and to all four download formats with it. That is the exact failure the redaction exists to prevent, arriving through the one line in the block nobody checked.

  2. The same line became the public headline, because emphasis was stripped only at the ends. The directory rendered Operated by:** X (someone@example.com) — the markup and the address, on a page whose own comment says the contact block is kept out of the summary because "a directory page listing a hundred email addresses is a mailing list for whoever fetches it once".

A headline is now cleaned of markup anywhere, and one containing an address is dropped rather than trimmed: what is left after cutting the address out of that sentence is not a headline anybody wrote. A signed-in reader still sees everything.

Tests

test/capacity.test.ts, 18 cases: per-agent vs swarm pricing in both directions, the derived-vs-quoted distinction, the ways people write "one agent", aliases, currency, formatting, the nonsense-count guard, both privacy bugs, and that a signed-in copy still keeps the address.

Full suite: 137 passing, typecheck clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_016DEUXec5um4FY3EbqfwtYm

An agent's resume has to answer a question a human one never had to: is
this one agent, or ten running in parallel, and what does that cost? It
is the difference between a contractor and a firm, and nothing on the
board carried it.

Two contact-block keys carry it now, because that block is already where
a resume keeps its scalars and candidates.ts is explicit that a profile
restating the document is a second copy to keep in step:

    - **Agents**: 10
    - **Rate**: $100/hour/agent

The card and the JSON both read "10 agents, $100/hr each, $1,000/hr
total". The per-agent marker is the whole parsing problem: "$1,000/hour"
and "$100/hour/agent" from the same candidate are the same money, and an
unmarked rate is therefore read as the swarm price. Reading it the other
way would quote an employer ten times the real number, which is the
expensive direction to be wrong in.

A count with no rate is still capacity ("10 agents, rate on request").
A rate with no count is not, and comes back null: a price whose unit is
unknown is not information, it is something an employer would budget
against. An unstated capacity is rendered as unstated rather than
omitted, because a blank row reads as "one agent" to anyone skimning and
that is the wrong default for someone running ten.

The convention is optional, like every other rule in OpenResume, which
says in as many words that it has no required fields. Every resume
written before today has no capacity and all of them still render.

`agenticjobs ask-capacity` asks the people who are missing it. Backfill
was the alternative and it is the wrong one -- nobody here knows whether
a given candidate is one agent or ten, and inventing an answer puts a
made-up price on a real person's profile. It prints the recipients and
stops; --send is a separate word because this is the only command in the
CLI that writes to other people.

Two privacy bugs found while reading live data, both fixed here:

Redaction only withheld preamble *bullets* that parsed as contact
fields. An address written as prose under the name went out to every
signed-out reader and to all four download formats -- and one was live,
in a real candidate's profile. That is the exact failure the redaction
exists to prevent, arriving through the one line in the block nobody
checked.

The same line became the public headline, because emphasis was stripped
only at the ends: the directory showed `Operated by:** X (addr)`, with
the markup and the address in it. A headline is now cleaned of markup
anywhere, and one containing an address is dropped rather than trimmed
-- what is left after cutting an address out of that sentence is not a
headline anybody wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016DEUXec5um4FY3EbqfwtYm
@ralyodio
ralyodio merged commit 6de6adc into master Sep 9, 2026
4 checks passed
ralyodio added a commit that referenced this pull request Sep 9, 2026
)

Documenting the two flows turned up the reason they had never been
documented: the CLI could not do them. There was no way to create an
employer without a browser, and `resume save` sent no visibility, so a
resume saved from a terminal was private with no command to list it. The
page had to tell you to copy a bearer token out of config.json and curl
the API, on a board whose whole pitch is that a client can drive it.

Employers get the rest of their CRUD:

- PATCH /api/v1/orgs/{slug} changes only the fields it carries. An absent
  field is left alone and an explicit null clears it, so a caller that
  knows about a name cannot blank a website it never read.
- A rename never moves the slug. That slug is the URL every listing, link
  and directory row already points at, and a company becoming "Example
  Works Inc" has not become a different employer.
- DELETE /api/v1/orgs/{slug} refuses with 409 once anything has gone live.
  jobs.org_id cascades and applications.job_id cascades behind it, so the
  honest delete is one that will not quietly take published listings and
  the applications people sent to them. Drafts do not count: nobody has
  seen one, and an employer whose listings are all drafts is almost always
  one typed in wrong five minutes ago.

Resumes get the visibility half they were missing: `--visibility` on save
and import, `resume publish`, `unpublish`, `visibility <slug> <value>` and
`delete <slug> --yes`. A typo'd value is refused rather than dropped,
which is what the old silent isVisibility filter did.

"Saved." is now the address instead: the only question a candidate has
after saving is whether anyone can read it yet, and the URL is the answer.

Both flows are two commands on /docs now, with the REST calls kept as the
equivalent rather than the instruction. Also documents the swarm capacity
keys from #46, which shipped without reaching the page.

Verified end to end against a real board, not only in tests: created an
employer, renamed it and watched the slug hold, posted the exact front
matter the docs page shows, published it, had the delete refused, then
saved a resume, published it, and read the listed candidate back with its
skills split and its capacity priced.

Tests: 210 pass against Postgres. The four new ones cover the rename, the
partial patch, a stranger's 403 and the delete guard. The OpenAPI
coverage test caught both new routes missing from the published document,
which is what it is for.


Claude-Session: https://claude.ai/code/session_01D8ai32QMBsKEs1nigijSiJ

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Sep 10, 2026
Minor rather than patch because commands and endpoints were added, and
because the reason to cut this at all is that /docs is currently ahead of
the published client: it tells people to run `agenticjobs employer create`
and `agenticjobs resume publish`, and npm still ships 0.10.0, where
neither exists.

Since 0.10.1:

- Employers and resume visibility as CRUD (#48). employer
  list/show/create/update/delete, resume publish/unpublish/visibility/
  delete and --visibility on save and import, with PATCH and DELETE
  /api/v1/orgs/{slug} behind them. A rename keeps the slug; an employer
  that has published cannot be deleted.
- Getting listed and hiring are documented at all (#45), and swarm
  capacity on a candidate profile (#46).
- An address is withheld wherever it is written, and never from cache
  (#47).
- Boolean CLI flags no longer swallow the command after them (#49).
- Salary bounds read correctly in summaries and compare correctly across
  periods in search (#50, #51).
- A JSON parse failure on a 200 is reported rather than swallowed (#52).
- Job and DOCX imports keep titles, structure, tabs and line breaks
  (#53, #54).

Both version files move together: package.json and VERSION in
src/config.ts.


Claude-Session: https://claude.ai/code/session_01D8ai32QMBsKEs1nigijSiJ

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant