GPAgent

GPAgent API v1

Programmatic access to your fund data. Every endpoint returns JSON and is authenticated with a bearer API key. GPAgent is the source of truth — any client (including OpenClaw) should read and write through this API rather than keep a local mirror.

  • Base URL (production): https://www.gpagent.ai/api/v1
  • Base URL (local dev): http://localhost:3000/api/v1
  • Auth header: Authorization: Bearer gpa_...
  • Content type: application/json

Create and manage keys at /app/settings/api in the web UI. Keys are per-user and can be revoked at any time.

Use the www. host. The apex gpagent.ai issues a 307 redirect to www.gpagent.ai, and most HTTP clients (curl, fetch, reqwest) drop the Authorization header on cross-host redirects as a security measure. Pointing at www.gpagent.ai directly avoids an unauthenticated retry and the resulting 401.


Conventions

Identifiers

Portfolio companies are addressed by a URL-safe slug (e.g. phases, anthum-ai). If you don't supply one on create, the API derives it from name. Slugs are unique per vehicle and stable across renames.

Vehicle scoping

Every endpoint operates on a single vehicle (fund or SPV). If your API key has access to exactly one vehicle, it's selected automatically. If you have multiple, pass ?vehicleId=... on every request — the API returns 400 with the list of accessible vehicle IDs otherwise.

Soft delete

DELETE on most resources is a soft delete — the row is marked with deletedAt and excluded from list and detail reads by default. Pass ?includeDeleted=true on a list/detail GET to bypass the filter (useful for admin/debug views).

For the top-level /portfolio/{slug} and /deals/{slug} resources, re-creating via PUT with the same slug clears deletedAt and restores the record.

Portfolio intelligence resources (investor updates, customers, news items) expose explicit POST /.../restore endpoints for soft-deleted rows. Operational/derived resources (metric snapshots, data gaps, fund stats snapshots) are HARD-deleted instead — they are recomputable from upstream sources, so we don't keep tombstones. Hard deletes return 204.

Idempotency

VerbBehavior
POST /portfolioStrict create. Returns 409 if the slug already exists.
PUT /portfolio/{slug}Upsert. Safe to retry. Replaces the full record at that slug.
PATCH /portfolio/{slug}Partial update. 404 if the slug does not exist.
DELETE /portfolio/{slug}Soft delete. Safe to retry (idempotent).

For sync use-cases, prefer PUT — it's the only write verb safe to retry after a network failure without worrying about duplicate state.

Errors

All errors return JSON with at least an error field:

{ "error": "Invalid or revoked API key" }

Validation errors include a zod issues array so clients can point at the offending field.

StatusMeaning
400Invalid JSON, failed validation, or ambiguous vehicle scope
401Missing, malformed, or revoked API key
404Resource not found, soft-deleted, OR exists in another organization (see existence-mask below)
409Slug conflict on POST /portfolio

Authentication & Authorization

API keys are scoped to one or more organizations (Membership rows on the underlying user). Every portfolio-intel endpoint enforces this scope.

Existence-mask convention (since Phase 4.6, 2026-05-08): when a request targets a resource that exists but belongs to an organization the API key isn't a member of, the response is 404 Not Found with the same JSON body as a genuine "resource doesn't exist" response. The status code, the error.code (NOT_FOUND), and the error.message are identical between those two cases.

The reason: a 403/404 split is a side-channel. An attacker holding a valid gpa_* key could iterate company slugs, vehicle ids, news-item ids, etc., and learn which exist by observing 403 (exists, wrong org) vs 404 (doesn't exist). Returning 404 everywhere closes that channel.

Forensic distinction is preserved server-side via console.log audit lines (look for [audit] cross-org); only the HTTP response is masked.

Breaking changes

  • 2026-05-09 (Phase 4.8a): GET /portfolio and GET/PUT/PATCH/DELETE /portfolio/{slug} now honor a ?vehicleSlug=<slug> query param. Without it, multi-vehicle GPs were silently served their first vehicle (the same class of mistake as the Phase 4.7 POST default). Missing/empty values fall back to the historical default for backwards-compat. Bad slugs return 404 (existence-mask).
  • 2026-05-08 (Phase 4.7): POST /portfolio now requires an explicit vehicleSlug in the request body. Previously the endpoint used the caller's first vehicle as a silent default, which on 2026-05-08 caused six personal pre-fund investments to be created under the wrong vehicle's id. Required fields enforce a decision; defaults invite drift. Discover valid vehicleSlug values via the new GET /vehicles endpoint. Requests missing vehicleSlug now return 400; requests with a slug that doesn't exist (or belongs to another organization) return 404 (existence-mask).
  • 2026-05-08 (Phase 4.6): endpoints that previously returned 403 FORBIDDEN for cross-org access now return 404 NOT_FOUND instead. Affects every /api/v1/portfolio/* endpoint that takes a slug, vehicle id, or resource id (companies, investor updates, metric snapshots, customers, data gaps, news items, stats snapshots). Update any client that branched on the 403 status — it can't happen any more for cross-org access. 403 is no longer emitted by the portfolio-intel surface.

Endpoints

GET /portfolio

List a fund vehicle, its fund model, and all (non-soft-deleted) portfolio companies.

Accepts ?vehicleSlug=<slug> (Phase 4.8a, 2026-05-09). When omitted, the endpoint falls back to the caller's default vehicle for backwards-compat. When provided, the slug is resolved against the caller's organizations using the same existence-mask convention as POST /portfolio — a slug that doesn't exist (or belongs to another organization) returns 404.

# Default vehicle
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio

# Explicit vehicle (multi-vehicle GPs should always pass this)
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/portfolio?vehicleSlug=pre-fund-i"

The per-slug routes (GET/PUT/PATCH/DELETE /portfolio/{slug}) accept the same ?vehicleSlug= query param for the same reason.

Response 200

{
  "vehicle": {
    "id": "cmx...",
    "name": "Element 14 Capital Fund I",
    "type": "FUND",
    "slug": "fund-i"
  },
  "fund": {
    "targetFundSize": 1050000,
    "targetCompanyCount": 15,
    "maxConcentrationPct": 25,
    "totalDeployed": 275000,
    "deploymentPct": 26.2,
    "avgCheckSize": 27500,
    "avgValuation": 26100000,
    "companyCount": 10
  },
  "companies": [
    {
      "id": "cmx...",
      "slug": "phases",
      "name": "Phases",
      "website": "https://phases.so",
      "sector": "Developer Tools",
      "stage": "pre-seed",
      "checkSize": 25000,
      "postMoneyValuation": 20000000,
      "investmentDate": "2025-11-26T00:00:00.000Z",
      "instrumentType": "SAFE",
      "status": "ACTIVE",
      "notes": "YC S25. AI clinical trial recruitment.",
      "logoUrl": null,
      "createdAt": "2025-11-26T19:00:00.000Z",
      "updatedAt": "2026-03-12T10:00:00.000Z"
    }
  ],
  "sectors": [{ "sector": "AI/ML", "count": 3, "deployed": 85000 }]
}

POST /portfolio

Create a new portfolio company in the explicitly-selected vehicle. Strict create — if a company with the same slug already exists, returns 409. Use PUT /portfolio/{slug} instead for safe-retry upserts.

Hard requirement (Phase 4.7, 2026-05-08): vehicleSlug is required. The endpoint no longer falls back to a default vehicle. This prevents accidental vehicle co-mingling — e.g. a personal pre-fund investment landing under the LP-backed Fund I vehicle. Discover valid slugs via GET /vehicles.

Request body (JSON)

FieldTypeRequiredNotes
namestringyesDisplay name
vehicleSlugstringyesSlug of the vehicle this investment belongs to. List via GET /vehicles.
slugstringnoLowercase alphanumeric + hyphens. Derived from name if omitted.
websitestring (URL)no
sectorstringno
stagestringnoe.g. pre-seed, seed, series-a
checkSizenumberyesUSD, non-negative
postMoneyValuationnumbernoUSD, non-negative
investmentDatestring (ISO 8601)yes
instrumentTypeenumyesSAFE | EQUITY | CONVERTIBLE
statusenumnoACTIVE (default) | MARKED_UP | WRITTEN_DOWN | EXITED
notesstringno
logoUrlstring (URL)no
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Phases",
    "vehicleSlug": "fund-i",
    "website": "https://phases.so",
    "sector": "Developer Tools",
    "stage": "pre-seed",
    "checkSize": 25000,
    "postMoneyValuation": 20000000,
    "investmentDate": "2025-11-26",
    "instrumentType": "SAFE",
    "notes": "YC S25."
  }' \
  https://www.gpagent.ai/api/v1/portfolio

Responses

  • 201 — the created company (same shape as GET /portfolio/{slug}).
  • 400 — validation failed (missing vehicleSlug, malformed values, etc).
  • 404vehicleSlug doesn't exist, or it belongs to another organization (existence-mask).
  • 409 — a company with the same slug already exists in this org.

GET /portfolio/{slug}

Fetch a single portfolio company by slug.

curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/phases

Response 200

{
  "id": "cmx...",
  "slug": "phases",
  "name": "Phases",
  "website": "https://phases.so",
  "sector": "Developer Tools",
  "stage": "pre-seed",
  "checkSize": 25000,
  "postMoneyValuation": 20000000,
  "investmentDate": "2025-11-26T00:00:00.000Z",
  "instrumentType": "SAFE",
  "status": "ACTIVE",
  "notes": "YC S25.",
  "logoUrl": null,
  "createdAt": "2025-11-26T19:00:00.000Z",
  "updatedAt": "2026-03-12T10:00:00.000Z"
}

PUT /portfolio/{slug}

Upsert a portfolio company by slug. Full-record replace. Safe to retry — this is the recommended write verb for sync clients.

Body shape matches POST /portfolio. slug in the body, if present, must match the URL slug.

curl -X PUT \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Phases",
    "website": "https://phases.so",
    "sector": "Developer Tools",
    "stage": "pre-seed",
    "checkSize": 25000,
    "postMoneyValuation": 20000000,
    "investmentDate": "2025-11-26",
    "instrumentType": "SAFE",
    "status": "MARKED_UP",
    "notes": "Marked up after their seed round."
  }' \
  https://www.gpagent.ai/api/v1/portfolio/phases

Response 200 on update, 201 on create. Body is the resulting company.


PATCH /portfolio/{slug}

Partial update. Only the fields you send are changed; everything else is left alone. Returns 404 if the slug does not exist.

curl -X PATCH \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "MARKED_UP", "notes": "Seed round announced."}' \
  https://www.gpagent.ai/api/v1/portfolio/phases

Response 200 — the updated company.


DELETE /portfolio/{slug}

Soft-delete a portfolio company. The record is retained in the database with deletedAt set and excluded from future reads. A subsequent PUT /portfolio/{slug} will restore it.

curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/phases

Response 204 (no body).


Using GPAgent as a backing store for an agent

If you're wiring this API into an agent (OpenClaw, Claude Code, a custom script), the practical rules are:

  1. Never cache. Query the API at the moment you need the data. GPAgent is cheap and always current.
  2. Use PUT for writes. It's the only verb safe to retry after a timeout.
  3. Pipe the curl output through jq if you want to let the agent read specific fields without parsing the whole response.
  4. Persist the API key outside the agent's working directory. For OpenClaw, it belongs in ~/.openclaw/openclaw.json under env.GPAGENT_API_KEY.
  5. Add this file to your agent's context. The doc is served at /api-docs — point your agent's extra-context list at it (for OpenClaw: agents.defaults.memorySearch.extraPaths).


Deal Pipeline

Deals are org-scoped (not vehicle-scoped) since a pipeline spans all funds. Vehicle auth is still required to identify your organization — the resolved organizationId is used.

GET /deals

List all active deals in your pipeline. Supports filtering, pagination.

Query parameters

ParamTypeNotes
sourceenumYC | ANGELLIST | REFERRAL | INBOUND | MANUAL
stageenumDISCOVERED | RESEARCHING | PRE_MEETING | MEETING | TERMS | COMMITTED | PASSED
batchstringFilter by sourceDetail (case-insensitive contains). E.g. batch=W26
limitnumberMax records to return. Default 50, max 200.
offsetnumberPagination offset. Default 0.
# All RESEARCHING deals from YC W26
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/deals?stage=RESEARCHING&batch=W26"

Response 200

{
  "total": 12,
  "limit": 50,
  "offset": 0,
  "deals": [
    {
      "id": "cma...",
      "slug": "raindrop",
      "companyName": "Raindrop",
      "website": "https://raindrop.dev",
      "description": "Sentry for AI agents.",
      "source": "YC",
      "sourceDetail": "YC W26",
      "stage": "RESEARCHING",
      "thesisFitScore": 5,
      "estimatedValuation": 20000000,
      "estimatedCheckSize": null,
      "sector": "AI/ML",
      "companyStage": "seed",
      "notes": "...",
      "logoUrl": null,
      "links": [],
      "scoutedByAgentId": "cma...",
      "createdAt": "2026-03-22T10:00:00.000Z",
      "updatedAt": "2026-03-22T10:00:00.000Z"
    }
  ]
}

POST /deals

Create a new deal. Strict create — returns 409 if a deal with the same slug already exists for this org.

Request body (JSON)

FieldTypeRequiredNotes
companyNamestringyes
slugstringnoDerived from companyName if omitted.
websitestring (URL)no
descriptionstringno
sourceenumnoDefault MANUAL
sourceDetailstringnoe.g. YC W26
stageenumnoDefault DISCOVERED. One of DISCOVERED, RESEARCHING, PRE_MEETING, MEETING, TERMS, COMMITTED, PASSED.
thesisFitScorenumber (0–10)no
estimatedValuationnumbernoUSD
estimatedCheckSizenumbernoUSD
sectorstringno
companyStagestringnoe.g. pre-seed, seed
notesstringno
logoUrlstring (URL)no
linksarrayno[{ type, label, url }]
scoutedByAgentIdstringnoAgent that sourced this deal

Response 201 — the created deal.


GET /deals/{slug}

Fetch a single deal by slug. Returns the deal plus all comments.

curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/deals/raindrop

Response 200 — deal object with comments array appended:

{
  "id": "cma...",
  "slug": "raindrop",
  "companyName": "Raindrop",
  "...",
  "comments": [
    {
      "id": "cmc...",
      "dealId": "cma...",
      "authorName": "Scout",
      "authorType": "agent",
      "content": "High thesis fit. Prioritize at Demo Day.",
      "metadata": {},
      "createdAt": "2026-03-22T10:05:00.000Z"
    }
  ]
}

PUT /deals/{slug}

Upsert a deal by slug. Full-record replace. Safe to retry.

Body shape matches POST /deals. slug in the body, if present, must match the URL slug.

Response 200 on update, 201 on create.


PATCH /deals/{slug}

Partial update. Send only the fields you want to change. Useful for stage transitions.

# Advance a deal to MEETING
curl -X PATCH \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"stage": "MEETING"}' \
  https://www.gpagent.ai/api/v1/deals/raindrop

Response 200 — the updated deal. 404 if slug not found.


DELETE /deals/{slug}

Soft-delete a deal. Sets deletedAt; excluded from future reads. A subsequent PUT restores it.

Response 204 (no body).


POST /deals/{slug}/comments

Add a comment to a deal.

Request body (JSON)

FieldTypeRequiredNotes
authorNamestringyesDisplay name
authorTypeenumnohuman (default) | agent | system
contentstringyes
metadataobjectnoArbitrary JSON. Default {}
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"authorName": "Scout", "authorType": "agent", "content": "Confirmed round is open."}' \
  https://www.gpagent.ai/api/v1/deals/raindrop/comments

Response 201 — the created comment.


Scoring Criteria

Organization-level scoring rubric used by the Scout agent to evaluate deals.

GET /scoring-criteria

Return the current scoring criteria for your organization.

curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/scoring-criteria

Response 200

{
  "organizationId": "cma...",
  "organizationName": "Element 14 Capital",
  "criteria": {
    "domainExpertise": {
      "4": "Founder/market fit. Complementary team...",
      "3": "Some domain knowledge...",
      "2": "Limited background...",
      "1": "No relevant background..."
    },
    "product": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "fundraisability": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "market": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "execution": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "traction": { "4": "...", "3": "...", "2": "...", "1": "..." }
  }
}

If no custom criteria are set, returns the E14 Capital defaults.


PUT /scoring-criteria

Full replace of the scoring criteria. All 6 categories and all 4 score levels are required.

curl -X PUT \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domainExpertise": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "product": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "fundraisability": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "market": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "execution": { "4": "...", "3": "...", "2": "...", "1": "..." },
    "traction": { "4": "...", "3": "...", "2": "...", "1": "..." }
  }' \
  https://www.gpagent.ai/api/v1/scoring-criteria

Response 200 — the saved criteria (same shape as GET).



Vehicles

Vehicles are the unit of fund accounting in GPAgent. One organization can own many vehicles (e.g. fund-i, fund-ii, an SPV, a personal pre-fund book). Every PortfolioCompany belongs to exactly one vehicle.

List your vehicles before calling POST /portfolio so you can pass the right vehicleSlug. Slugs are stable, human-readable, and unique within an organization.

GET /vehicles

List all vehicles owned by organizations the caller has memberships in. Sorted by createdAt ascending (oldest first).

curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/vehicles

Response 200

{
  "items": [
    {
      "id": "cmx...",
      "slug": "fund-i",
      "name": "Fund I",
      "type": "FUND",
      "description": "LP-backed Element 14 Capital Fund I",
      "organizationId": "cmy...",
      "companyCount": 10,
      "createdAt": "2024-06-01T00:00:00.000Z"
    },
    {
      "id": "cmz...",
      "slug": "pre-fund-i",
      "name": "Pre-Fund I",
      "type": "FUND",
      "description": "Personal angel investments before Fund I",
      "organizationId": "cmy...",
      "companyCount": 6,
      "createdAt": "2025-06-01T00:00:00.000Z"
    }
  ]
}

Returns an empty items array if the caller has no organization memberships.


Portfolio Intelligence

These endpoints handle raw investor update storage, metric snapshots, customer logos, data gaps, and fund-level statistics. All are scoped to the caller's organization(s).

POST /portfolio/companies/{slug}/investor-updates

Upload a raw investor update for a portfolio company.

Idempotency: if sourceRef is provided and already exists for this company, the existing row is returned with status 200 instead of creating a duplicate.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "receivedAt": "2026-01-15T10:00:00Z",
    "source": "email",
    "sourceRef": "gmail-thread-abc123",
    "fromAddress": "founder@startup.com",
    "subject": "Q4 2025 Investor Update",
    "rawBody": "Hi investors, here is our Q4 update..."
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates

Response 201 — created. 200 — existing row returned (idempotent sourceRef match).


GET /portfolio/companies/{slug}/investor-updates

List investor updates for a company.

Query params: needsExtraction=metrics|customers, limit (default 50, max 200), cursor (id-based pagination).

Response 200

{ "items": [...], "nextCursor": "<id or null>" }

GET /portfolio/companies/{slug}/investor-updates/{id}

Fetch a single investor update by id.

curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates/update-id

POST /portfolio/companies/{slug}/metric-snapshots

Append a metric snapshot for a portfolio company. Always creates a new row (append-only time series).

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "asOf": "2026-01-01T00:00:00Z",
    "reportedPeriod": "2025-Q4",
    "arrUsd": 1200000,
    "mrrUsd": 100000,
    "headcount": 18,
    "runwayMonths": 18
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/metric-snapshots

Response 201 — the created snapshot.


GET /portfolio/companies/{slug}/metric-snapshots

Read the metric time series for a company. Returns in descending asOf order.

Query params: from (ISO date), to (ISO date), limit (default 100, max 500).


POST /portfolio/companies/{slug}/customers

Upsert a customer logo. Matches on (companyId, customerNameNormalized) — if a customer with the same normalized name already exists it is updated; otherwise created.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customerName": "Acme Corporation",
    "tier": "F500",
    "status": "ACTIVE",
    "firstSignedAt": "2025-09-01T00:00:00Z"
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers

Tiers: F100, F500, ENTERPRISE, MIDMARKET, SMB, GOV, EDU, OTHER
Statuses: ACTIVE, PAUSED, CHURNED, PILOT, LOST_DEAL

Response 201 (created) or 200 (updated).


GET /portfolio/companies/{slug}/customers

List customers for a company.

Query params: status (filter), tier (filter).


POST /portfolio/companies/{slug}/customers/{id}/churn

Mark a customer as churned. Idempotent — if already CHURNED, returns the current row without modification.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "churnedAt": "2026-03-15T00:00:00Z", "notes": "Went with competitor" }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers/cust-id/churn

POST /portfolio/companies/{slug}/data-gaps

Create a data gap report for a company. Always creates a new row.

Gap types: ATTACHMENT_UNREAD, LINK_UNREAD, METRIC_MISSING, CUSTOMER_AMBIGUOUS, PERIOD_UNCLEAR

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "gapType": "METRIC_MISSING",
    "description": "ARR not found in Q4 update body",
    "suggestedField": "arrUsd"
  }' \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/data-gaps

GET /portfolio/data-gaps

List data gaps across all companies in the caller's organizations.

Query params: status (default OPEN), companyId (optional filter), limit (default 50).


POST /portfolio/data-gaps/{id}/resolve

Resolve a data gap with a provided value. Side-effect endpoint, not just a status flip. Resolving propagates the value to its destination in the data model:

Gap typeSide-effect on resolve
METRIC_MISSINGAppends a PortfolioCompanyMetricSnapshot row with the resolved value in the column named by suggestedField (e.g. metrics.arrUsd).
CUSTOMER_AMBIGUOUSUpserts a PortfolioCompanyCustomer row from the structured payload {customerName, tier, status?, dealValueAnnualUsd?, contractTerm?, notes?}.
PERIOD_UNCLEARUpdates the source PortfolioCompanyInvestorUpdate.reportedPeriod field with the resolved string.
ATTACHMENT_UNREAD, LINK_UNREADPasted content is saved on resolvedValue. The next weekly extraction run reads it. We do not auto-trigger LLM calls from this endpoint (cost discipline).

Idempotent — resolving an already-resolved gap returns the current state without re-running side-effects.

Response: the resolved gap plus an effects: [{kind, detail, ref?}] array describing what changed in the data model.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "resolvedValue": 1200000, "notes": "Confirmed via follow-up call" }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/gap-id/resolve

POST /portfolio/data-gaps/{id}/dismiss

Mark a data gap as DISMISSED ("not going to fill this in"). Distinct from RESOLVED (provides a value) and DELETE (removes the row). Idempotent. Body is optional.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "notes": "Founder confirmed metric not relevant" }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/gap-id/dismiss

POST /portfolio/data-gaps/bulk-resolve

Resolve up to 100 data gaps in a single request. Per-item partial success: a bad item does not 4xx the batch.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resolutions": [
      { "gapId": "gap-1", "resolvedValue": 1200000 },
      { "gapId": "gap-2", "resolvedValue": "2026-Q1" }
    ]
  }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/bulk-resolve

Response:

{
  "summary": { "resolved": 2, "alreadyResolved": 0, "notFound": 0, "invalid": 0, "total": 2 },
  "items": [
    { "gapId": "gap-1", "status": "resolved", "gap": {...}, "effects": [...] },
    { "gapId": "gap-2", "status": "resolved", "gap": {...}, "effects": [...] }
  ]
}

Per-item statuses: resolved, already-resolved, not-found (existence-mask: gap missing OR cross-org), invalid (validation error during the per-item write).


POST /portfolio/data-gaps/bulk-dismiss

Dismiss up to 100 data gaps in a single request. Per-item partial success.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "gapIds": ["gap-1", "gap-2", "gap-3"], "notes": "All three resolved offline" }' \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/bulk-dismiss

Per-item statuses: dismissed, already-dismissed, not-found.


POST /portfolio/news

Bulk-ingest portfolio company news items. Auto-publish thresholds depend on source tier and confidence; idempotent per URL.

Org scoping (since 2026-05-07, Phase 4.5): every item is scope-checked against the caller's organizations. If companySlug resolves to a company outside the caller's orgs, that item is rejected with status: "rejected_wrong_org" and the rest of the batch continues — partial success preserved (a single bad item must not 403 the other 99).

Response item statuses:

StatusMeaning
publishedNew row created.
updatedExisting row updated (idempotent re-ingest).
rejected_low_confidenceconfidence below the source-tier threshold.
rejected_no_mentionTier-2 source with no in-text company mention.
rejected_company_unknownNo portfolio company with that slug.
rejected_wrong_orgThe company exists but belongs to a different organization (Phase 4.5).
rejected_invalidReserved for future use.
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{
      "companySlug": "phases",
      "url": "https://techcrunch.com/2026/01/10/phases-raises-series-a",
      "title": "Phases raises $5M Series A",
      "publishedAt": "2026-01-10T09:00:00Z",
      "confidence": 0.95
    }]
  }' \
  https://www.gpagent.ai/api/v1/portfolio/news

Response 200

{ "summary": { "published": 1, "updated": 0, "rejected": 0, "total": 1 }, "items": [...] }

POST /portfolio/news/maintenance

Self-healing maintenance pass for the news feed. Idempotent and safe to call on any cadence. Designed for a daily cron.

Org scoping (since 2026-05-07, Phase 4.5): all three operations are filtered to news items whose company belongs to one of the caller's organizations. The filter is applied at the SQL layer (companyId IN (orgCompanyIds)). If the caller has no accessible companies, the endpoint short-circuits to zero stats and zero work.

What it does:

  1. Re-validates URLs of items not validated in the last 7 days. 4xx responses hide the item; 5xx and network errors are treated as transient.
  2. Hides items older than 365 days from publishedAt to keep the feed fresh.
  3. Returns materialNegative items from the last 24 hours so a downstream cron can fan out alerts (Telegram, email, etc.).
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/news/maintenance

Response 200

{
  "ok": true,
  "stats": {
    "revalidated": 12,
    "autoHidden": 1,
    "archivedByAge": 0,
    "newNegatives": 0
  },
  "materialNegatives": []
}

POST /portfolio/stats/recompute

Recompute and persist a PortfolioStatsSnapshot for a fund vehicle. Sums ARR across all portcos' latest metric snapshots, computes median YoY growth multiple, and counts F100/F500 customer logo coverage.

Accepts either vehicleSlug (preferred, matches the rest of v1) or vehicleId (deprecated cuid form, kept for backwards compat with pre-Phase-7b callers — notably the Phase 6 fund-intelligence cron). At least one is required; if both are provided, vehicleSlug wins. 400 if neither is provided.

# Preferred form (Phase 7b+):
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "vehicleSlug": "fund-i" }' \
  https://www.gpagent.ai/api/v1/portfolio/stats/recompute

# Legacy / deprecated cuid form (still works):
curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "vehicleId": "<vehicle-cuid>" }' \
  https://www.gpagent.ai/api/v1/portfolio/stats/recompute

Response 201

{
  "id": "snap-1",
  "vehicleId": "v1",
  "portfolioArrUsd": 8500000,
  "portfolioArrYoyMultiple": 3.2,
  "enterpriseLogoCoverage": 0.6,
  "totalF500Customers": 6,
  "totalActiveCustomers": 10
}

GET /portfolio/stats/latest

Return the most recent PortfolioStatsSnapshot for a vehicle.

Query params: vehicleSlug (preferred) or vehicleId (deprecated cuid form). At least one is required; if both are passed, slug wins.

# Preferred form (Phase 7b+):
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/portfolio/stats/latest?vehicleSlug=fund-i"

# Legacy / deprecated cuid form (still works):
curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  "https://www.gpagent.ai/api/v1/portfolio/stats/latest?vehicleId=<vehicle-cuid>"

Soft delete + restore (portfolio intelligence)

These endpoints let you remove and restore portfolio intelligence rows. List/detail GETs filter deletedAt IS NOT NULL by default; pass ?includeDeleted=true to bypass.

DELETE /portfolio/companies/{slug}/investor-updates/{id}

Soft-delete an investor update. Idempotent — already-deleted rows return 200 with the existing soft-deleted row.

Does NOT cascade to derived metric snapshots that referenced this update via sourceUpdateId. Snapshots are append-only and stand on their own evidence; the sourceUpdateId becomes a dangling pointer by design.

curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates/upd-id

Response 200 — the soft-deleted row.


POST /portfolio/companies/{slug}/investor-updates/{id}/restore

Restore a soft-deleted investor update by clearing deletedAt. Idempotent.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/investor-updates/upd-id/restore

Response 200 — the restored row.


DELETE /portfolio/companies/{slug}/metric-snapshots/{id}

HARD delete a metric snapshot. Snapshots are derived/append-only data and recomputable from upstream sources, so we don't keep tombstones. Idempotent at the resource level (404 once gone).

curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/metric-snapshots/snap-id

Response 204 (no body).


DELETE /portfolio/companies/{slug}/customers/{id}

Soft-delete a customer. Idempotent. Distinct from CHURNED status: CHURNED represents a real-world churn event; deletedAt is data hygiene (wrong row, accidental import).

curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers/cust-id

Response 200 — the soft-deleted row.


POST /portfolio/companies/{slug}/customers/{id}/restore

Restore a soft-deleted customer. Idempotent.

curl -X POST \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/companies/phases/customers/cust-id/restore

Response 200 — the restored row.


DELETE /portfolio/data-gaps/{id}

HARD delete a data gap. Idempotent at the resource level.

curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/data-gaps/gap-id

Response 204 (no body).


GET /portfolio/news/{id}

Fetch a single news item by id. Soft-deleted items are excluded unless you pass ?includeDeleted=true.

Org scoping (since Phase 4.5): if the news item belongs to a portfolio company outside the caller's organizations, the endpoint returns 404 (not 403) so cross-org existence isn't leaked via id-iteration.

curl -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/news/news-id

DELETE /portfolio/news/{id}

Soft-delete a news item. Idempotent. Distinct from hidden: hidden de-prioritizes an item in the user-facing feed; deletedAt removes it from the API surface entirely.

Org scoping (since Phase 4.5): cross-org access returns 404, same convention as the GET endpoint.

curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/news/news-id

Response 200 — the soft-deleted row.


DELETE /portfolio/stats/{id}

HARD delete a fund stats snapshot. Stats are derived from current portfolio state and recomputable at any time via POST /portfolio/stats/recompute. Idempotent at the resource level.

curl -X DELETE \
  -H "Authorization: Bearer $GPAGENT_API_KEY" \
  https://www.gpagent.ai/api/v1/portfolio/stats/snap-id

Response 204 (no body).


Changelog

  • 2026-05-08 — Phase 7b-0: /portfolio/stats/recompute and /portfolio/stats/latest now accept vehicleSlug in addition to vehicleId. Slug is preferred and matches the rest of v1; cuid form is documented as deprecated but still works (the Phase 6 fund-intelligence cron uses it). At least one identifier is required; if both are provided, slug wins. Non-breaking — the previous cuid-only contract is preserved.
  • 2026-05-08Breaking: Phase 4.7: POST /portfolio requires explicit vehicleSlug. New GET /vehicles endpoint to list available slugs. Motivated by a 2026-05-08 incident where six personal pre-fund investments were created under the LP-backed Fund I vehicle because the endpoint silently used the caller's first vehicle. Required fields enforce a decision; defaults invite drift. See "Breaking changes" above.
  • 2026-04-16 — Added PRE_MEETING to DealStage enum (sits between RESEARCHING and MEETING); agents own DISCOVERED/RESEARCHING, GPs own PRE_MEETING onward.
  • 2026-04-16GET/POST /deals, GET/PUT/PATCH/DELETE /deals/{slug}, POST /deals/{slug}/comments, GET/PUT /scoring-criteria.
  • 2026-05-08Breaking: Phase 4.6: standardize 404-everywhere for cross-org access. Every portfolio-intel endpoint that previously returned 403 for cross-org access now returns 404 with a response body indistinguishable from a genuine missing resource. See "Authentication & Authorization" and "Breaking changes" above. Affects: every /portfolio/* endpoint with a slug/id parameter; the news endpoints already followed this convention since Phase 4.5.
  • 2026-05-07 — Phase 4.5: org-scope every news endpoint. POST /portfolio/news rejects wrong-org items per-item (new rejected_wrong_org status) and continues the batch. POST /portfolio/news/maintenance filters every query by companyId IN (caller's orgs). GET/DELETE /portfolio/news/{id} return 404 for cross-org access (existence-mask).
  • 2026-05-07 — Phase 4: DELETE + restore endpoints across portfolio intelligence surface. Soft-delete (with ?includeDeleted=true admin override) for investor updates, customers, news items; hard-delete for metric snapshots, data gaps, fund stats snapshots.
  • 2026-05-07 — Phase 2: Portfolio intelligence endpoints (investor updates, metric snapshots, customers, data gaps, news ingest, fund stats).
  • 2026-04-10 — v1 launch: GET/POST /portfolio, GET/PUT/PATCH/DELETE /portfolio/{slug}. Deal pipeline and Updates endpoints shipping next.