Aptora for AI assistants

Connect Aptora to Claude, ChatGPT or Cursor and let your assistant build profiles, search the job board and apply on your behalf. Everything on this page works with a free account.

The server address

Point any MCP-capable client at this URL. There are no API keys: the first request opens a browser window where you sign in and choose what to grant.

MCP server URL
https://aptora.io/mcp

Transport: streamable HTTP (JSON-RPC). Authorization: OAuth 2.1 with PKCE and dynamic client registration, so most clients need nothing but the URL.

Connect your client

Pick the assistant you use. Each takes under a minute.

Cursor

  1. 1Use the “Add to Cursor” button on our home page, or paste the config below into ~/.cursor/mcp.json.
  2. 2Cursor opens a browser window once. Sign in to Aptora and confirm the permissions you want to grant.
  3. 3Ask the agent in chat — the Aptora tools appear automatically.
~/.cursor/mcp.json
{
  "mcpServers": {
    "aptora": {
      "url": "https://aptora.io/mcp"
    }
  }
}

Claude

  1. 1In Claude, open Settings → Connectors → Add custom connector, and paste the server URL.
  2. 2Confirm “Add”, then sign in to Aptora when Claude asks to authorize.
  3. 3Start a new chat — Aptora shows up in the tools menu.
Claude Code, from a terminal
claude mcp add --transport http aptora https://aptora.io/mcp

ChatGPT

  1. 1Enable developer mode: Settings → Apps and connectors → Advanced → Developer mode.
  2. 2Create a new connector, paste the server URL and confirm.
  3. 3Sign in to Aptora when ChatGPT asks to authorize, then start a chat with the connector enabled.
server URL
https://aptora.io/mcp

Interactive components (a rendered profile card instead of raw JSON) need the connector loaded as an app in developer mode. Without it the tools still work and answer with plain data.

Things to ask for

Copy one and change the details. Each uses several tools together — your assistant chains them on its own.

Import a résumé

Here is my CV as a PDF export. Build an Aptora profile from it, keep every position and project, and check it validates before you save.

Find work

Search the Aptora job board for remote freelance backend roles, show me the three best matches with day rates, and open the details of the most interesting one.

Apply to a job

Apply to that job with my backend profile. Draft the application mail for me to review — do not send anything yet.

Keep a profile current

Add my new role at Nordkapp Systems, starting last September, to my main profile. Leave every other section exactly as it is.

Source candidates (team workspaces)

Publish this job spec to our board, then find matching candidates and shortlist the top three with a note on why.

What your assistant can do

Tools are grouped by what they touch. Your client only sees the ones you granted at sign-in.

Profiles

Read, create, update and delete your profiles, plus the full résumé schema and a dry run that reports what a write would store.

Job board

Browse and filter public listings and read a full job spec, including the application contact where a listing carries one.

Applications

File an application with a chosen profile and get back a ready-to-send mail. Nothing is ever sent for you.

Recruiting

For team workspaces: write and publish your own listings, search candidates, keep a shortlist and reach out. Needs a plan that includes them.

Tools, one by one

Every tool the server exposes, with its inputs and the grant it needs. This list is generated from the server itself, so it cannot say something the server no longer does.

Generated from the MCP server's tool registry — names, descriptions and inputs are the ones a connected client sees.

list_cvsread-onlycv.read

List all of the signed-in user's CVs (id, name, theme).

Takes no arguments.

get_cvread-onlycv.read

Fetch a single CV including its full resume data, plus `profile_skills`: the canonical skills detected on it, with the `skill_id` list_jobs filters by.

  • cv_id · integer · requiredThe CV id.
get_cv_schemaread-onlyno grant needed

Return the full AptoraCV resume JSON Schema that CV `data` must conform to. Prefer this over the `aptora://cv-schema` resource — it is a plain tool call and works on every client.

Takes no arguments.

get_capabilitiesread-onlyno grant needed

What this server can store: the supported AptoraCV sections, the section/field aliases it accepts, the allowed enum values and the supported import features (strict, merge, normalization). Call this before importing a resume from another format.

Takes no arguments.

create_cvwritescv.write

Create a new CV from a COMPLETE AptoraCV resume document. Preserve every field and array entry from your source — do not drop or empty populated sections. Call `get_cv_schema` for the full schema and `validate_cv` for a dry-run first. Common non-AptoraCV wording is normalized automatically (see `normalize`); pass strict=true to have the write rejected instead of losing anything. A field AptoraCV does not have is rejected outright, naming the field to use instead. IDENTITY: in a PERSONAL workspace a profile is always about the signed-in user, so firstName/lastName/name are replaced with the account's own name and the candidate name you send is discarded. That is intended and is NOT data loss — everything else is stored as sent, and the response reports the change under `normalized` with rule `personal_workspace_identity`. To keep someone else's name, import into a TEAM workspace.

  • data · object · requiredThe COMPLETE AptoraCV resume document. When importing an existing CV, preserve every field and every array entry from the source — never replace a populated section (employment.history, projects, education.history, …) with an empty array or omit it. The response echoes how many entries were stored per section so you can verify nothing was lost. The properties below are an outline: every section is named, but only one level deep. Call `get_cv_schema` for the exact types, enums and nested fields, and `validate_cv` to dry-run a document before writing it.
  • strict · booleanReject the whole import (writing nothing) if any section arrived with more entries than were stored. Default false: the import proceeds and the response reports the difference. A field that is not part of AptoraCV is always rejected, strict or not.
  • normalize · booleanMap common resume wording onto AptoraCV before validating — section aliases (awards→recognition, work→employment), field aliases (startDate→start, awarder→from) and enum values ("master"→"Expert", "Master's Degree"→"degree"). Default true; the response lists every change under `normalized`.
  • name · string · requiredA name/label for the CV.
  • theme · stringOptional theme id. Omitted, the CV starts on the workspace's default profile theme (settings, 'Profiles').
update_cvdestructivecv.write

Update a CV's name, data and/or theme. Only provided fields change. By default `data` REPLACES the stored document, so any section you omit is lost — pass merge=true to patch the stored CV instead (sections you send replace their counterpart, sections you omit are kept). The response reports received vs. stored counts per section. In a PERSONAL workspace the candidate name is always the signed-in account's — see create_cv.

  • data · objectThe COMPLETE AptoraCV resume document. When importing an existing CV, preserve every field and every array entry from the source — never replace a populated section (employment.history, projects, education.history, …) with an empty array or omit it. The response echoes how many entries were stored per section so you can verify nothing was lost. The properties below are an outline: every section is named, but only one level deep. Call `get_cv_schema` for the exact types, enums and nested fields, and `validate_cv` to dry-run a document before writing it.
  • strict · booleanReject the whole import (writing nothing) if any section arrived with more entries than were stored. Default false: the import proceeds and the response reports the difference. A field that is not part of AptoraCV is always rejected, strict or not.
  • normalize · booleanMap common resume wording onto AptoraCV before validating — section aliases (awards→recognition, work→employment), field aliases (startDate→start, awarder→from) and enum values ("master"→"Expert", "Master's Degree"→"degree"). Default true; the response lists every change under `normalized`.
  • cv_id · integer · requiredThe CV id.
  • name · string
  • theme · string
  • merge · booleanMerge `data` into the stored document instead of replacing it. Objects merge recursively; a list or scalar you send replaces the stored one. Default false.
validate_cvread-onlycv.read

Dry-run: validate a AptoraCV resume document WITHOUT writing anything. Returns whether it is valid, any schema errors, the normalization it would apply, how many entries each section would store versus how many you sent, and any field that is not part of AptoraCV. Use this before create_cv/update_cv to confirm no data is lost. In a PERSONAL workspace the reported normalization includes the identity binding the write would apply, so this dry run and the write agree.

  • data · object · requiredThe COMPLETE AptoraCV resume document. When importing an existing CV, preserve every field and every array entry from the source — never replace a populated section (employment.history, projects, education.history, …) with an empty array or omit it. The response echoes how many entries were stored per section so you can verify nothing was lost. The properties below are an outline: every section is named, but only one level deep. Call `get_cv_schema` for the exact types, enums and nested fields, and `validate_cv` to dry-run a document before writing it.
  • strict · booleanReject the whole import (writing nothing) if any section arrived with more entries than were stored. Default false: the import proceeds and the response reports the difference. A field that is not part of AptoraCV is always rejected, strict or not.
  • normalize · booleanMap common resume wording onto AptoraCV before validating — section aliases (awards→recognition, work→employment), field aliases (startDate→start, awarder→from) and enum values ("master"→"Expert", "Master's Degree"→"degree"). Default true; the response lists every change under `normalized`.
delete_cvdestructivecv.write

Delete (soft-delete) a CV by id.

  • cv_id · integer · requiredThe CV id.
list_jobsread-onlyjobs.read

List job listings from the Aptora job board that require at least one of the given skills, the listings requiring most of them first and the newer one within an equal count. `skills` is mandatory and needs at least 3 skill ids: the board is searched, never listed whole. Skill ids come from get_cv (`profile_skills`) or from any listing's `required_skills`; an id that names no active skill is refused by name. Every summary carries the listing's `required_skills`, so one call answers which listings fit and why: the result is a board the user pages and opens each listing in, so do not walk the rows with get_job. A bare skill search is served up to page 10; further pages need a narrowing filter (type, title, company or location). Pages and listings of other workspaces are subject to a fair-use read budget: a refusal carries `retry_after` in seconds. Fair use is what the terms of service set out in § 17.

  • skills · array<integer> · requiredSkill ids to filter by, at least 3. A listing matches when it requires any of them.
  • type · enumOnly freelance or only permanent positions.
  • title · stringSubstring filter on the job title.
  • company · stringSubstring filter on the company.
  • location · stringSubstring filter on the location.
  • page · integer1-based page number (default 1).
  • page_size · integerJobs per page (default 20, max 50).
get_jobread-onlyjobs.read

Fetch one job listing including the full job spec, its `required_skills` (with the `skill_id` list_jobs filters by) and the application contact (contact_email/contact_person) when the listing carries one. For one listing the user singled out: a list_jobs result already opens each of its rows in place, so fetching a page row by row stacks up one card per job and says nothing the list did not. Listings of other workspaces are subject to a fair-use read budget of distinct listings per hour and day; re-reading one is free, and a refusal carries `retry_after` in seconds. Fair use is what the terms of service set out in § 17.

  • job_id · string · requiredThe job id from list_jobs.
apply_to_jobdestructiveapplications.write

Apply to a job with one of the user's CVs. The listing's `origin` (from get_job) decides what happens: a `team` listing is DELIVERED into the owning workspace — `email` comes back null, there is nothing left for the user to send, and a contact_email passed for it is ignored. An `external` listing composes an application email (Gmail compose URL + markdown template) addressed to the listing's contact instead — nothing is sent; if it carries no contact_email, ask the user for the recipient and pass contact_email. IMPORTANT: applying PUBLISHES the chosen CV at a public share link anyone can open, and visits to it are tracked. Ask the user for explicit confirmation and pass confirm_publish=true only after they agreed.

  • job_id · string · requiredThe job id from list_jobs.
  • cv_id · integer · requiredThe CV to apply with.
  • theme · enumTheme for the shared CV link (default: the CV's theme).
  • day_rate · integerOffered day rate in EUR (freelance jobs only).
  • contact_email · stringRecipient for an `external` listing — required if it carries no contact_email; ignored for a `team` listing.
  • contact_person · stringContact person for the salutation (optional).
  • confirm_publish · boolean · requiredMust be true; confirms the user agreed to publishing their CV at a public, visit-tracked link.
list_applicationsread-onlyapplications.write

List the user's job applications, newest first, each with its composed application email — null for one delivered into a team workspace, whose `status` says where it stands instead. Needs a PERSONAL workspace, exactly like apply_to_job.

Takes no arguments.

get_job_schemaread-onlyno grant needed

The field schema for creating/updating a job listing. Static — call it before the first write instead of guessing field names.

Takes no arguments.

validate_jobread-onlyjobs.write

Dry-run a job listing: reports what is missing or would be ignored, and writes NOTHING. Use it before create_job to avoid half-finished listings. Available on every plan, including plans that cannot create listings; detect_skills is the one part that is not.

  • title · string · requiredThe listing's headline, e.g. 'Platform Engineer'. Write the role — not an ad subject line with location, duration or reference number in it. The role behind the taxonomy is named by the detection run reading the whole spec, so a headline never has to carry it.
  • company · string · requiredHiring company.
  • type · enum · requiredEngagement type.
  • published_since · string · requiredISO date the listing went live.
  • job_spec · stringThe full job description. This is what the required skills are detected from — a listing without it will not match any candidates.
  • location · string
  • remote_in_percent · integerShare of remote work. 0-100.
  • start_date · stringISO date.
  • end_date · stringISO date — only when the ad names an actual date. Most ads state a duration instead; that belongs in duration_months, never computed into a date here.
  • duration_months · integerStated engagement length in months, e.g. 12 for '12 MM'. 1-60.
  • duration_open_ended · booleanTrue when the duration carries a '+'/'++'/'mit Option auf Verlaengerung' — the ad says the length is open upward. It qualifies duration_months and needs one: a create without a duration drops it, an update without one is refused.
  • dayrate · integerEUR/day (freelance). 0-10000.
  • salary · integerEUR/year (permanent). 0-10000000.
  • job_url · string
  • contact_email · string
  • contact_person · string
  • expires_at · stringISO datetime; defaults to the configured window.
  • detect_skills · booleanAlso report which skills this job_spec would be matched on — the property that decides whether the listing finds anyone. Runs the detector, so it needs a plan that includes job skill detection; without one the rest of the validation still answers. Off by default.
create_jobwritesjobs.write

Create a job listing owned by this workspace. It starts PRIVATE: off the public board and out of candidate matching until publish_job is called on it. Requires a team workspace and nothing else — posting is free on every team plan. Skill detection on the saved spec runs only if the plan includes it.

  • title · string · requiredThe listing's headline, e.g. 'Platform Engineer'. Write the role — not an ad subject line with location, duration or reference number in it. The role behind the taxonomy is named by the detection run reading the whole spec, so a headline never has to carry it.
  • company · string · requiredHiring company.
  • type · enum · requiredEngagement type.
  • published_since · string · requiredISO date the listing went live.
  • job_spec · stringThe full job description. This is what the required skills are detected from — a listing without it will not match any candidates.
  • location · string
  • remote_in_percent · integerShare of remote work. 0-100.
  • start_date · stringISO date.
  • end_date · stringISO date — only when the ad names an actual date. Most ads state a duration instead; that belongs in duration_months, never computed into a date here.
  • duration_months · integerStated engagement length in months, e.g. 12 for '12 MM'. 1-60.
  • duration_open_ended · booleanTrue when the duration carries a '+'/'++'/'mit Option auf Verlaengerung' — the ad says the length is open upward. It qualifies duration_months and needs one: a create without a duration drops it, an update without one is refused.
  • dayrate · integerEUR/day (freelance). 0-10000.
  • salary · integerEUR/year (permanent). 0-10000000.
  • job_url · string
  • contact_email · string
  • contact_person · string
  • expires_at · stringISO datetime; defaults to the configured window.
update_jobdestructivejobs.write

Update fields of one of this workspace's job listings. Only the fields you name change; pass null to clear one. Saving never re-runs the skill detection; after a spec change pass detect_skills=true to re-detect — otherwise the listing keeps its previously detected skills.

  • job_id · string · required
  • title · stringThe listing's headline, e.g. 'Platform Engineer'. Write the role — not an ad subject line with location, duration or reference number in it. The role behind the taxonomy is named by the detection run reading the whole spec, so a headline never has to carry it.
  • company · stringHiring company.
  • type · enumEngagement type.
  • published_since · stringISO date the listing went live.
  • job_spec · string | nullThe full job description. This is what the required skills are detected from — a listing without it will not match any candidates.
  • location · string | null
  • remote_in_percent · integer | nullShare of remote work. 0-100.
  • start_date · string | nullISO date.
  • end_date · string | nullISO date — only when the ad names an actual date. Most ads state a duration instead; that belongs in duration_months, never computed into a date here.
  • duration_months · integer | nullStated engagement length in months, e.g. 12 for '12 MM'. 1-60.
  • duration_open_ended · booleanTrue when the duration carries a '+'/'++'/'mit Option auf Verlaengerung' — the ad says the length is open upward. It qualifies duration_months and needs one: a create without a duration drops it, an update without one is refused.
  • dayrate · integer | nullEUR/day (freelance). 0-10000.
  • salary · integer | nullEUR/year (permanent). 0-10000000.
  • job_url · string | null
  • contact_email · string | null
  • contact_person · string | null
  • detect_skills · booleanAlso re-detect which skills the saved spec is matched on — the property that decides whether the listing finds anyone. Runs the detector, so it spends one detection run and needs a plan that includes job skill detection. Off by default.
delete_jobdestructivejobs.write

Delete one of this workspace's job listings. Refused while candidates are still waiting on it — the refusal says how many; repeat the call with reject_open_applications=true to delete anyway.

  • job_id · string · required
  • reject_open_applications · booleanReject every application still waiting on this listing and delete it. Each of those candidates is notified that their application was rejected. Off by default: without it a listing with open applications is not deleted.
publish_jobwritesjobs.publish

Put one of this workspace's listings on the public board and into candidate matching. Reversible with unpublish_job — the listing keeps its id, its skills and its match history either way.

  • job_id · string · required
unpublish_jobdestructivejobs.publish

Take one of this workspace's listings back off the public board and out of candidate matching. The listing itself stays.

  • job_id · string · required
list_team_jobsread-onlyjobs.write

The listings this workspace owns, including expired ones. Different from list_jobs, which is the public board.

  • page · integer
  • page_size · integerDefault 20, max 50.
search_candidateswritestalent.read

Find candidates for a job (job_id) or for free criteria (filters) across every profile whose owner opted into being found. Ranks them by skill overlap and job-title fit and returns the evidence behind each score. Every search needs an anchor: a job_id (one of this workspace's listings, whose detected skills are the requirements) or at least 3 ids of active skills in the filters — those are what a candidate is ranked against. Every filter EXCLUDES on top of that: skills, titles, country, remote share and day rate each narrow the set, and no score buys a candidate back in. Within one list any entry counts, across lists all of them must. Skill ids come from a listing's required skills or a search result; an id that names no active skill is refused. This is DETERMINISTIC — it runs no model and forms no opinion beyond the counted overlap; call refine_search afterwards if you want that. Results never contain contact data — use send_outreach to reach someone. If the run comes back pending, poll get_search with the returned run_id. Runs and delivered profiles are subject to a fair-use read budget — fair use is what the terms of service set out in § 17 — and a refusal carries `retry_after` in seconds.

  • job_id · stringOne of this workspace's jobs.
  • filters · object
  • force · booleanRe-run instead of reusing.
refine_searchwritestalent.read

Have a model judge the candidates a finished search found: seniority against the role, how central the matching skills are to what someone actually does, whether the role plausibly interests them, and whether the missing skills are the kind one picks up. Re-ranks the SAME candidates — it never adds or removes anyone, and it never re-scores the counted overlap. Optional 'focus' is what matters beyond the listing ("must have worked in a regulated environment"). Costs one AI assessment from the workspace allowance; asking the same thing of the same run again is free. Poll get_search and read refine_status.

  • run_id · string · requiredA completed search run.
  • focus · stringOptional. What to weigh beyond the listing itself.
  • force · booleanRe-assess instead of reusing.
get_searchread-onlytalent.read

Status and a page of results for a run started by search_candidates. 'status' is the search itself; 'refine_status' is the optional AI assessment and is null until refine_search is called. Results are complete and ranked either way — a failed assessment leaves them untouched. Paging costs no allowance.

  • run_id · string · required
  • page · integer
  • page_size · integerDefault 25, max 100.
list_searchesread-onlytalent.read

This workspace's candidate searches, newest first, one page at a time.

  • page · integer
  • page_size · integerDefault 20, max 50.
shortlist_candidatewritestalent.write

Save a candidate to the workspace shortlist, optionally against a job. The shortlist belongs to the whole team.

  • cv_id · integer · required
  • job_id · string
  • note · string
list_shortlistread-onlytalent.read

The workspace shortlist, optionally filtered by job, one page at a time.

  • job_id · string
  • page · integer
  • page_size · integerDefault 20, max 50.
remove_from_shortlistdestructivetalent.write

Drop one entry from the workspace shortlist.

  • shortlist_id · integer · required
send_outreachdestructivetalent.outreach

SENDS A REAL EMAIL TO A REAL PERSON. One call, one recipient — there is no bulk parameter and there will not be one. The message is delivered by Aptora from our address; you never receive the candidate's address, and they can reply directly if they want to. A candidate may be contacted by this workspace at most once every 30 days, and may switch outreach off entirely. Ask the user before calling this.

  • cv_id · integer · required
  • job_id · stringThe role this is about.
  • subject · string · required
  • body · string · requiredPlain text; no HTML.

What you are granting

The sign-in screen lists exactly which of these a client asked for, and you can refuse any of them. Tools you did not grant are not even offered to the assistant.

You can review and revoke any connection at any time under Settings → Integrations.

Good to know

  • Applying publishes the profile you chose at a public share link, and visits to it are counted. Your assistant has to ask you before it does that.
  • In a personal workspace a profile is always about you: an imported candidate's name is replaced with your account name. Import someone else's résumé into a team workspace to keep theirs.
  • Candidate results never contain contact details. Reaching a person goes through Aptora, one recipient at a time, and each candidate can switch it off.
  • Aptora drafts application mail. It never sends mail on your behalf.

If something does not work

The assistant says it has no Aptora tools

The connection is not authorized yet, or you granted a narrower set of permissions than the task needs. Reconnect and check the boxes on the sign-in screen.

It keeps asking me to sign in

Access expires after an hour and refreshes silently. If it does not, remove the connector in your client and add it again — the old grant may have been revoked.

I only see JSON, no profile card

Interactive components are rendered by the client, not by us. In ChatGPT they need the connector loaded as an app in developer mode; the data itself is correct either way.

A tool is offered but fails when called

The client is holding a cached tool list from an older version of the server. Reconnect the connector to refresh it.

Keep reading