v0.2 Draft — feedback welcome Apache 2.0

Kitsuno Handshake

An open protocol for agent-to-agent hiring. A2A handles the transport. Hiring needs a domain protocol above it — for disclosure tiers, consent, verification, and the moment humans cross the bridge.

The problem we're solving#

Hiring agents now exist on both sides of the table. Job seekers use agents that auto-apply to hundreds of postings. Companies use agents to filter, screen, and rank. The result is an arms race — agents talking past each other, drowning in volume, with no shared contract for who's allowed to say what to whom, when, and with what consent.

Google's A2A protocol (April 2025) gave agents a way to discover and communicate with each other. It's the transport layer. But it deliberately doesn't tell agents what they're negotiating about. Each vertical has to define its own semantics.

Commerce has ACP, AP2, UCP. Hiring has nothing. Until now.

Kitsuno is one implementation of Handshake v0.2 — we built the first train. The rails are open.

The protocol is Apache 2.0. ATSes, job platforms, recruiter agencies, and individual seekers can all participate without registering with Kitsuno. Federation is a first-class primitive.

The protocol: three disclosure tiers#

Handshake maps the recruiter funnel into three tiers of disclosure between agents. L1 and L2 are agent-to-agent. L3 is the moment humans cross the bridge.

Tier Layer Recruiter-funnel equivalent What's disclosed
L1 Machine → machine, public Public job-ad surface (LinkedIn / Stepstone equivalent) What any candidate would see on a job board: title, role family, seniority, location, employment type, skills, language requirements, salary range (EU pay-transparency directive defaults), description, posting visibility (named or confidential).
L2 Machine → machine, after interest is signalled by either party Application form + recruiter pre-screen Detailed role description, full skill list, tech stack, benefits, screening questions answered by the seeker agent from the principal's career record (visa status, notice period, salary expectation, "Why this role?" — answered structurally where possible, generated where not).
L3 Human → human, after vacancy poster releases Recruiter screening call / first interview handoff Hiring manager identity, exact compensation breakdown, confidential client identity (for retained search), interview process detail, calendar booking link, internal team context, performance metrics. The candidate's identity and full portfolio become visible to the poster at the same moment.

Posters configure per-field which tier reveals which information. Defaults align with standard ad practice and the EU pay-transparency directive; the tier_overrides field lets posters demote or promote specific fields for confidential searches or sensitive comp data.

State machine#

A single handshake between a seeker and a vacancy is one conversation with a deterministic, monotonic state flow — disclosure only ever advances, with consent at each step. There is one entry state, l1_fired, reached from either direction: a seeker agent firing autonomously during discovery (initiated_by: agent), or a human applying directly to a specific vacancy (initiated_by: human — the case we call direct apply). Both run the same deterministic policy_match (L1) and the same validator strong-fit gate (L2); they differ only in who initiated and what is therefore owed in return.

agent (discovery) initiated_by: agent human (direct apply) initiated_by: human l1_fired single entry interest signalled either party l2_disclosed seeker → vacancy L3 resolved per intake × consent declined · not_advanced (L2 gate) · expired

Each transition is HMAC-signed (Stripe-style, with timestamp). Every event is idempotent — receivers deduplicate by event_id. A conversation terminates as declined (a party said no), not_advanced (it failed the L2 validator gate), or expired (the L1 TTL lapsed, default 14 days). One conversation exists per (seeker, vacancy) pair: a direct apply to a vacancy already met in discovery attaches to the existing conversation_id and advances it — never a duplicate, never a reset.

Intake modes & resolution#

L3 — the crossing into identity — is a two-sided preference resolved at match time, not a fixed direction. The vacancy declares how it takes identity; the seeker declares whether identity may be disclosed without a human in the loop. Neither side can override the other.

Vacancy intake (l3_intake_mode on the vacancy card): on_request — screened intake; receive L2, a human reviews and requests L3 per candidate. on_match — open intake; the L3 of any L2-eligible candidate whose consent permits it is auto-delivered to the vacancy's endpoint. Default on_request; an agent that does not understand the field treats it as on_request.

Seeker consent (consent_policy.agent_may_release_l3_without_human_review on the seeker card, or an inline l3_at_fit on a direct application): off — the seeker is asked each time before identity is disclosed. on — the seeker has already said yes; disclosure fires without asking, and the seeker is told it happened.

Vacancy l3_intake_modeSeeker consent to auto-discloseWhat happens at L2-eligible
on_matchonL3 auto-discloses to the vacancy endpoint; seeker receives an l3_disclosed signal naming where it went.
on_matchoffSeeker is asked (l3_initiated signal); on yes, L3 discloses.
on_requestonVacancy reviews L2 and requests; because the seeker pre-consented, release is frictionless.
on_requestoffVacancy reviews L2 and requests; the seeker decides per request — mutual consent.

Application requirements — the readiness predicate on this grid. A vacancy may declare application_requirements: materials the applicant produces and delivers (a portfolio link, a work sample, a written answer, a repo), as opposed to l2_structured_questions answered from the seeker's standing PRS. Their content is L3 — at L2 each is only a provided / not-provided boolean. The grid above resolves only once the required requirements are satisfiable: if a required item is not provided, the match is not rejected — the seeker is invited to apply ("you match; provide X"), and providing X is the application and the situated L3 consent for this conversation. So even under on_match + consent on, L3 auto-discloses only when every required requirement is already satisfiable from the seeker's standing card; otherwise the seeker applies first. wished / supporting requirements never block. Which requirement types a standing card can pre-satisfy versus which demand an active apply is an operator implementation choice (the protocol fixes only the rule: required && not provided → invite to apply).

The seeker invariant. A vacancy's on_match preference never forces disclosure. Consent flows one direction — seeker to vacancy — always. The vacancy controls its own intake and cost; the seeker controls whether, and when, identity crosses.

The handshake delivers the L3 event to the conversation's registered endpoint and stops there. What a receiving product does with it — draft an outreach, create a recruiter-inbox row, fire a CRM webhook, or nothing — is operator-defined and outside the protocol. The handshake itself drafts and sends nothing. Likewise, whether and how any resolution is billed is each operator's own policy; the protocol carries no price.

Signals: who hears what#

The governing rule for what gets reported back is one line:

Human interaction that creates signal is reported back to the counterparty; agent-scale discovery that no human acted on terminates silently.

A passively-discoverable seeker fans across thousands of L1/L2 conversations; the overwhelming majority die quietly at the policy or validator gate. Reporting every one back would be the exact noise the protocol exists to remove — so ambient discovery failures are silent. Only meaningful, human-touched, or identity-crossing edges emit a signal, delivered to the party it is owed to:

The validator: anti-spam at the L2 boundary#

The state machine above describes the protocol's shape. But there is one decision point inside it that deserves its own treatment: after l2_disclosed and before either side surfaces the conversation to a human, a validator decides whether this handshake is worth asking a human to look at.

Without this gate, the protocol would forward every policy-clearing match to a human inbox. The point of the protocol is the opposite: a pipeline is a commitment surface, not a feed. If everything that passes the deterministic policy gate lands in front of a person, the person learns to stop looking — and the protocol has nothing to show for itself.

Three buckets, four dimensions

The validator returns one of three verdicts:

VerdictMeaningEffect on the conversation
strong_fit All four fit dimensions match. Worth a human's attention. Conversation becomes L3-eligible. Seeker surfaces the match.
weak_fit Adjacent but not core. Mixed dimension signals. Silent drop. Stored for analytics. No notification either side.
no_fit Structurally a different kind of work despite passing policy filters. Silent drop. Stored for analytics. No notification either side.

Every verdict includes four structured fit_dimensions for downstream analytics and tuning:

A low_signal flag is set automatically when the vacancy description is shorter than ~800 characters — distinguishing weak fit because the data is thin from weak fit because actually weak. Operators tune this threshold to their corpus.

Where it fires

The validator runs once per handshake conversation, after both L2 disclosures have been exchanged. The annotation on the state machine above — human release · L3 — is the moment the validator's verdict matters: a STRONG verdict makes the conversation L3-eligible (the human can choose to cross the bridge); WEAK or NO_FIT block the L3 release pathway without telling either party.

Output shape

The verdict is a strict JSON object. Implementations across any model or rubric must conform to this shape so downstream consumers (pipeline UIs, analytics, feedback loops) can interoperate:

{
  "verdict":      "strong_fit" | "weak_fit" | "no_fit",
  "reason":       "<one sentence, candidate-facing, ≤200 chars>",
  "fit_dimensions": {
    "role_alignment":   "match" | "partial" | "miss",
    "seniority_fit":    "match" | "partial" | "miss",
    "skill_overlap":    "match" | "partial" | "miss",
    "context_fit":      "match" | "partial" | "miss"
  },
  "low_signal":   true | false
}

Privacy properties

The validator's verdict is asymmetric by design.

Reference implementation

The handshake-validator package in the agents monorepo ships:

The protocol does not mandate any particular classifier. It mandates the contract: a (seeker_card, vacancy_card) pair in, a Verdict out, never raising. Operators running real handshakes at volume should expect to replace the rule-based baseline with a tuned classifier of their own.

The anti-spam principle in one line: be conservative about strong_fit. A missed strong is recoverable; a noisy pipeline is not.

Federation: protocol, not platform#

Handshake v0.2 is a federated standard, not a platform-locked API. Four layers stack to make "everyone can find everyone" work without anyone owning the federation:

1. The protocol primitive — well-known discovery

Every operator publishes a single document at /.well-known/handshake-v0.2.json on their own domain. This is the only thing that MUST exist for an operator to be findable. It declares the operator's endpoints (cards index, vacancy-signal, L2 delivery, L3 release), verifier keys, and supported features. See well-known.json.

Foreign agents that know an operator's domain GET this URL first. Everything else in the federation stack is a way to learn a domain you didn't already know.

2. The cards feed — what crawlers actually consume

Operators that want their cards to be discoverable point the cards_index field at a paginated feed. The feed format is specified in cards-feed.json: a list of (kind, slug, state_hash, uri) tuples, cursor-paginated, with optional ?since=<timestamp> for incremental crawls.

The state_hash is the federation's idempotency key. A foreign crawler caches (operator, slug, state_hash) tuples it has already evaluated and skips them on the next crawl. This is the same primitive a single operator uses internally to skip repeat scoring — exported across the federation boundary so the noise floor stays low even at protocol-wide scale.

3. Aggregators — many lists, none authoritative

An aggregator is a list of operators someone curates. Kitsuno hosts one at kitsuno.ai/handshake/v0.2/directory.json using the directory.json schema. This is one aggregator among many possible aggregators. The schema is the interface contract; the data is one operator's curation.

Anyone can run an aggregator — at their own domain following the directory.json schema, or as a plain GitHub repository, or as a Mastodon list, or as a shared spreadsheet. Multiple aggregators with different curation policies is the desired state, not a bug. A foreign agent picks whichever aggregator(s) it trusts.

The launch reference aggregator is at github.com/kitsuno-ai/handshake-discovery — a public, Apache-2.0, plain-JSON list of well-known URLs. Operators add themselves by opening a pull request. Forks are welcome and expected.

4. Announcement channels — propagation, not infrastructure

Operators MAY announce their well-known URL anywhere they reach their audience: a Mastodon post, a Moltbook entry, a BlueSky thread, a Substack note, a podcast mention, a tweet, a GitHub README, an email signature. The protocol does not specify any announcement channel. These are signal multipliers that point at the well-known URL — they are not part of the protocol's authoritative surface.

A future bot crawling the web for /.well-known/handshake-v0.2.json files is as valid a discovery mechanism as any aggregator. The federation has no gatekeeper.

Verifier attribution

Every verification block names its issuer in a verifier field. Kitsuno is the launch verifier, but any party can issue attestations — ATSes can self-verify, recruiter directories can cross-attest, future identity protocols can become verifiers. Receiving agents fetch <verifier>/.well-known/handshake-v0.2.json, read its verifier_keys, and decide whether to trust the attestation.

What this means in practice: Kitsuno propagates its own feed alongside everyone else, on the same terms. We are one node in a federation we don't own. If we disappeared tomorrow, every other operator's well-known URL would keep working, every aggregator could be re-curated by someone else, and the protocol would continue.

Schema reference#

All schemas are JSON Schema 2020-12. Each declares its $id at the canonical URL below, allowing validators to resolve $ref between them.

common.json

Foundation. Shared types — DisclosureTier, ConversationState, TrustTier, Verification, TierOverrides, HmacSignature, ISO date/country/language/currency types, Money, LanguageProficiency.

vacancy-card.json

L1 public surface of a vacancy. What seeker agents fetch to evaluate via policy_match before firing L1.

seeker-card.json

L1 surface of a seeker. What vacancy agents fetch after receiving an L1 fire to score the (vacancy, seeker) pair.

l1-fire.json

Seeker → vacancy. The interest signal fired after policy_match passes the vacancy's threshold.

vacancy-signal.json

Vacancy → seeker. Response to an L1 fire — interested = true triggers L2 disclosure.

l2-disclosure.json

Seeker → vacancy. Machine-readable disclosure with structured + free-form answers from the principal's career record.

l3-release.json

Vacancy → seeker. The human handoff — hiring manager identity, compensation breakdown, calendar, internal context.

seeker-application.json

Seeker → vacancy, human-initiated (direct apply). An L1 fire with initiated_by: human; carries the per-pair collision rule and optional l3_at_fit.

signal.json

Conversation result event. Unified signal_type (l2_delivered, l3_initiated, l3_disclosed, l2_no_match, l2/l3_refused) delivered to the party it is owed to.

directory.json

Public, paginated, opt-in directory of verified vacancy accounts. Federation primitive.

cards-feed.json

Paginated card index format. What an operator's cards_index URL returns — list of (kind, slug, state_hash, uri) tuples with cursor pagination and incremental crawl support.

well-known.json

Self-hosted discovery format. Any domain publishes this at /.well-known/handshake-v0.2.json to participate.

Example handshake#

A complete handshake from L1 fire to L3 release, three messages between the seeker agent at kitsuno.agent/u/alice and the vacancy agent at acme.example/v/eng-42.

L1 fire (seeker → vacancy)

POST /handshake/v0.2/inbox/internal HTTP/1.1
Host: acme.example
Handshake-Signature: t=1747300800,v1=8a3b...

{
  "event_type": "handshake.l1_fired",
  "event_id": "0193e8ac-3f21-7a90-bef0-1c8f4a5d2e91",
  "fired_at": "2026-05-15T10:00:00Z",
  "conversation_id": "0193e8ac-3f21-7a90-bef0-1c8f4a5d2e92",
  "seeker_card_url": "https://kitsuno.ai/handshake/v0.2/cards/alice.json",
  "seeker_card_id": "0193e8ac-...",
  "vacancy_card_id": "0193e8ac-...",
  "score": 87,
  "policy_match_result": {
    "matched_criteria": ["required_skills", "languages", "work_permit"],
    "unmatched_criteria": []
  },
  "callback_url": "https://kitsuno.ai/api/v0.2/conversations/0193e8ac.../vacancy-signal",
  "ttl_seconds": 1209600
}

L2 disclosure (seeker → vacancy, after vacancy_signaled_interest)

POST /api/v0.2/inbox HTTP/1.1
Host: acme.example
Handshake-Signature: t=1747300920,v1=c4f9...

{
  "event_type": "handshake.l2_disclosed",
  "event_id": "0193e8ad-...",
  "disclosed_at": "2026-05-15T10:02:00Z",
  "conversation_id": "0193e8ac-3f21-7a90-bef0-1c8f4a5d2e92",
  "disclosure_tier": "L2",
  "structured_answers": [
    { "question_id": "visa", "answer": true, "source": "prs_deterministic" },
    { "question_id": "notice_period_days", "answer": 30, "source": "prs_deterministic" },
    { "question_id": "salary_min",
      "answer": { "amount": 85000, "currency": "EUR", "period": "year" },
      "source": "principal_explicit" }
  ],
  "free_form_answers": [
    { "question_text": "Why this role?",
      "answer_text": "Alice's career record shows a consistent thread of..." }
  ],
  "skills": ["python", "kubernetes", "postgres", "system design"],
  "experience_summary": [
    { "title": "Senior Engineer", "company": "Previous Co",
      "start_date": "2022-01-01",
      "summary": "Led migration to event-driven architecture..." }
  ]
}

L3 release (vacancy → seeker, after human review)

POST /api/v0.2/conversations/0193e8ac.../release HTTP/1.1
Host: kitsuno.ai
Handshake-Signature: t=1747304400,v1=e1d8...

{
  "event_type": "handshake.l3_released",
  "event_id": "0193e8b0-...",
  "released_at": "2026-05-15T11:00:00Z",
  "conversation_id": "0193e8ac-3f21-7a90-bef0-1c8f4a5d2e92",
  "disclosure_tier": "L3",
  "released_by": {
    "account_id": "0193e8b0-...",
    "actor_type": "human",
    "display_name": "Sarah Chen, Head of Engineering"
  },
  "hiring_manager": {
    "name": "Sarah Chen",
    "title": "Head of Engineering",
    "linkedin_url": "https://linkedin.com/in/sarahchen-example"
  },
  "compensation_breakdown": {
    "base": { "amount": 95000, "currency": "EUR", "period": "year" },
    "bonus_target": { "amount": 12000, "currency": "EUR", "period": "year" },
    "equity_description": "0.05% over 4 years"
  },
  "calendar_link": "https://cal.example.com/sarah-chen/intro-30min",
  "next_step_message": "Hi Alice — your experience with event-driven migrations is exactly the wedge we're hiring for..."
}

Implementation#

Kitsuno operates the reference implementation of Handshake v0.2 at kitsuno.ai. It is one operator. The protocol does not require Kitsuno.

For ATSes

Integrate Handshake v0.2 in your platform to receive verified candidate intent from any compliant seeker. See for-employers to get started, or handshake@kitsuno.ai for ATS integration access. DNS verification + webhook challenge-response, no usage minimums during launch.

For agent builders

Reference implementations are at github.com/kitsuno-ai/kitso-handshake-agents under Apache 2.0. The agents repository includes a complete end-to-end test fixture demonstrating the L1 → L2 → L3 flow.

For job platforms and recruiter agencies

Sign up at for-employers, or self-host using the well-known.json pattern at your own domain.