Metadata-Version: 2.5
Name: lighthouse-private-markets-sdk
Version: 0.11.1
Summary: Python SDK for the Lighthouse REST API (CRM, Discovery, Reports, Documents).
Project-URL: Homepage, https://trylighthouse.vc
Author-email: Alberto Marzetta <hello@trylighthouse.vc>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.8
Requires-Dist: requests>=2.28
Description-Content-Type: text/markdown

# Lighthouse Python SDK

Official Python client for the [Lighthouse](https://trylighthouse.vc) REST API. Wraps the CRM, Discovery, Reports, and Documents endpoints behind a single typed `Lighthouse` class.

## Installation

```bash
pip install lighthouse-private-markets-sdk
```

## Quick Start

Generate an API key at **Settings → API Keys** in your Lighthouse workspace.

```python
from lighthouse import Lighthouse

client = Lighthouse("lgt_live_...")

# Who am I?
me = client.users.me()  # profile, workspace role, permissions and teams
print(me.data)

# Search your CRM
res = client.records.search(
    "company",
    filters={
        "operator": "AND",
        "conditions": [
            {"field": "domain", "operator": "contains_any", "value": ["acme.com"]}
        ],
    },
    limit=10,
)
print(res.data, res.meta)
```

Every method returns an `APIResponse`:

```python
class APIResponse:
    data:   Any                    # response payload
    error:  Any | None             # error message if the call failed
    meta:   dict | None            # pagination/meta info (when present)
    status: int                    # HTTP status code

res.raise_for_error()              # raises LighthouseError if error is set
```

## Authentication

The client sends `Authorization: Bearer <api_key>` on every request. Keep API keys secret. Never ship them in client-side code.

## Resources

| Namespace             | Endpoints |
|-----------------------|-----------|
| `client.records`      | search / get / create / update / delete records (company, person, deal, custom objects) |
| `client.lists`        | list / get / create / update / delete, get_records, add_record, remove_record, share/revoke teams, update_sharing |
| `client.notes`        | list / get / create / delete, link_record, unlink_record |
| `client.tasks`        | list / get / create / update / delete |
| `client.attributes`   | list / get / create / update / delete, colors |
| `client.options`      | list / create / delete select-field options |
| `client.views`        | list / get / update / delete, share/revoke teams, update_sharing |
| `client.users`        | me, list, get |
| `client.teams`        | list, get |
| `client.dashboards`   | list / get / create / update / delete, list_widgets, create_widget, update_widget, delete_widget, widget_data, share/revoke teams |
| `client.discovery`    | get_attributes, search_companies, search_people, semantic_search_companies, semantic_search_people, search_entities, get_entity_by_name, lookup_companies_by_domain, lookup_companies_by_linkedin, lookup_people_by_linkedin, get_saved_searches, get_search_results, get_locations, get_industries, get_tags, search_investors (deprecated), search_organizations (deprecated), search_schools (deprecated) |
| `client.documents`    | folders + files (v2): list_folders, create_folder, get_folder, update_folder, delete_folder, link/unlink_folder, upload_url, get_file, update_file, delete_file, link/unlink_file, list_record_documents, `upload()` one-shot helper |
| `client.inbox`        | email_account_me, email_accounts, email_threads, email_thread, create_email_draft, download_email_attachment, linkedin_account_me, linkedin_accounts, linkedin_chats, linkedin_chat_messages, download_linkedin_attachment, calendar_account_me, calendar_accounts, calendar_meetings, calendar_meeting, calendar_meeting_notes, calendar_meeting_transcript |
| `client.network`      | intro_paths |
| `client.ai_reports`   | list, get |

## Examples

### Inbox

Email, LinkedIn and calendar speak one vocabulary. The same concept is the same
key on every channel: `id`, `date`, `from`, `direction`, `text`, `is_read`,
`participants`, `last_message`, `attachments`, `account`. People are objects
(`{"name", "email"}` on email and calendar, a profile object on LinkedIn), empty
is `None`, `[]` or `False`, a fact reads `is_...` or `has_...`, and a link ends
in `_url`.

```python
# Accounts you can access (own and shared), with the permission flags.
# One account shape on all three channels: id, name, email, is_owner, provider,
# status, status_reason, owner, permissions, created_at. On LinkedIn email is
# None.
accounts = client.inbox.email_accounts()
own = [a for a in accounts.data if a["is_owner"]]

# Email threads with a CRM company (its domains), newest first.
# emails and domains take one address, a comma list or a list of strings.
threads = client.inbox.email_threads(
    record_id=company_id, record_type="company", is_read=False, limit=50
)
# threads.meta["accounts"] reports one entry per account you can see:
# id, name, email, is_owner, ok, error, count, where error is rate_limited,
# account_disconnected, forbidden and the like.
# A thread's preview lives inside last_message, beside who wrote it.
first = threads.data[0]
print(first["subject"], first["last_message"]["snippet"], first["account"]["is_owner"])

# Read one thread. account_id is required (the row's account id). order is
# "desc" (the default) or "asc", direction is "sent" or "received". An
# oldest-first read answers one page and no next_cursor.
detail = client.inbox.email_thread(
    first["id"], account_id=first["account"]["id"], order="asc"
)
# detail.data["has_limited_access"] is True when the account is shared for
# metadata only: body, text and both file lists come back empty.

# A message says who wrote it as an object, and carries the HTML body beside
# text, which is this message's own words with the quoted history removed.
msg = detail.data["messages"][0]
print(msg["from"]["name"], msg["from"]["email"], msg["text"])

# Download an attachment from a message (files embedded in the body are
# listed apart, under inline_images). Every file names its own message.
if msg["attachments"]:
    # raises LighthouseError over the 25 MB limit (413 attachment_too_large)
    content = client.inbox.download_email_attachment(
        msg["id"], msg["attachments"][0]["id"], account_id=msg["account"]["id"]
    )

# Draft a reply. The draft is saved in the mailbox, never sent, and comes back
# as an ordinary message with direction "draft". to, cc and bcc take an address,
# a comma list, a list or {"name", "email"} objects, so a message's own from or
# cc can be handed straight back. Write the message as text (plain) or as body
# (HTML), not both.
draft = client.inbox.create_email_draft(
    account_id=msg["account"]["id"],
    to=[msg["from"]],
    subject=f"Re: {msg['subject']}",
    text="Thanks for the note, following up shortly.",
    thread_id=detail.data["thread"]["id"],
    reply_to_message_id=msg["id"],
)

# LinkedIn: chats with a person, then one chat. is_read=False lists the chats
# that have unread messages; after and before read the chat's own date and both
# include the moment they name.
chats = client.inbox.linkedin_chats(
    person_linkedin="https://linkedin.com/in/username", is_read=False
)
chat_detail = client.inbox.linkedin_chat_messages(chats.data[0]["id"])
print([p["name"] for p in chat_detail.data["chat"]["participants"]])
print(chat_detail.data["messages"][0]["from"]["name"])

# Only the replies after a moment, oldest first. direction is "sent" or
# "received", order is "desc" (the default) or "asc". A date-only before
# includes that whole day. On a message the account sent, from is the account's
# own profile (connection_degree "self") and is_seen / is_delivered are the
# other side's receipts; both are None on a message it received.
replies = client.inbox.linkedin_chat_messages(
    chats.data[0]["id"],
    after="2024-03-01T09:30:00Z",
    direction="received",
    order="asc",
)

# Meetings on a connected calendar. meta["window"] is {"after", "before"}.
meetings = client.inbox.calendar_meetings(domains=["example.com"], upcoming=True)
print(meetings.data[0]["meeting_url"], meetings.data[0]["attendees"][0]["response_status"])

# One recorded meeting, then its notes and its transcript. meeting_key is the
# one handle that is the same for everybody who sat in the meeting, and a row
# carries it once the meeting was recorded.
key = next(m["meeting_key"] for m in meetings.data if m["recording"])
recorded = client.inbox.calendar_meeting(key)
if recorded.data["recording"]["has_notes"]:
    notes = client.inbox.calendar_meeting_notes(key)
    print(notes.data["content_markdown"], notes.data["is_edited"])

# meta is total_chars, offset_chars, returned_chars, has_more, next_offset_chars
transcript = client.inbox.calendar_meeting_transcript(key, offset_chars=0, max_chars=20000)
```

Three older parameter names are refused with a sentence rather than ignored, so
a filter can never be dropped in silence: use `emails` and `domains` in place of
`email` and `domain` on threads and meetings, and `is_read` in place of `unread`
on chats (`is_read=False` lists the chats that have unread messages).

### Network

Who can introduce you to a person, from what your team's LinkedIn networks
already say. This endpoint reads what is already known and never starts a new
search, so it is safe to call as often as you like. Searches are run from the
person's Network tab in Lighthouse.

```python
# Ask by CRM record, or by LinkedIn address for somebody who is not in your
# CRM. Send exactly one of record_id and linkedin: both together, or neither,
# is answered 400 invalid_input with a sentence naming the rule.
answer = client.network.intro_paths(record_id=person_id)

# Best paths first: the person who can make the introduction, the colleagues
# whose network found them, and how many connections they share with the
# person you want to reach. A path is person, location,
# shared_connections_count, found_via, record_id, searched_at.
for path in answer.data["paths"]:
    print(path["person"]["name"], [m["full_name"] for m in path["found_via"]],
          path["shared_connections_count"])
# connection_degree on a path is relative to the colleague who found them, not
# to you: "first" means a direct connection of that colleague, the warm intro.

# Why a path is missing is on the answer, so nothing has to be guessed.
# answer.data["is_searched"] False: nobody has looked for this person yet.
# is_searched True with answer.meta["total"] 0: a search found no shared
# connection.
# An account with is_searched False says why in not_searched_reason
# ("account_disconnected", "account_pending", "no_searches_left" or
# "not_searched_yet"), and searches_used / searches_limit say how much of this
# month is left on it. An account entry is id, name, email, is_owner,
# is_included, is_searched, searched_at, searches_used, searches_limit,
# not_searched_reason.
for account in answer.data["accounts"]:
    if not account["is_searched"]:
        print(account["name"], account["not_searched_reason"],
              account["searches_used"], account["searches_limit"])

# Narrow to certain accounts with account_ids: one id, a comma list or a list
# of the ids linkedin_accounts() returns. Every account you can see stays in
# accounts, and the ones you left out come back with is_included False. An id
# you cannot use is answered 404 not_found.
my_accounts = client.inbox.linkedin_accounts()
through_my_accounts = client.network.intro_paths(
    linkedin="https://www.linkedin.com/in/username",
    account_ids=[a["id"] for a in my_accounts.data if a["is_owner"]],
    limit=50,
)

# answer.meta is total, limit, offset, has_more, note. total counts the paths
# before paging. note is one sentence for a person to read, or None: branch on
# the keys, never on that text.
```

The answer covers your own LinkedIn accounts and the ones colleagues have
shared with you. A colleague who has not shared their network is not
represented at all, so a missing path can also mean a network this key cannot
see.

The person you asked about is `answer.data["person"]`, the LinkedIn person
shape the inbox uses (`name`, `linkedin`, `linkedin_url`, `avatar_url`,
`headline`, `connection_degree`), beside `record_id`,
`is_linkedin_connected`, `is_searched`, `searched_at` and `connected_members`,
the colleagues already connected to that person.

### Records

```python
# Create
created = client.records.create("company", data={
    "name": "Acme",
    "domain": ["acme.com"],
})
record_id = created.data["id"]

# Update: relation fields are replaced entirely, so pass the full desired array
client.records.update("company", record_id, data={"domain": ["acme.com", "acme.io"]})

# Get
client.records.get("company", record_id)

# Delete
client.records.delete("company", record_id)

# Upsert: find-or-create by a unique attribute. matching_value carries the
# value to match on, so it never has to be repeated inside data; leave it out
# to keep the older form, where the attribute inside data carries it.
client.records.upsert(
    "company", "domain", data={"name": "Acme"}, matching_value="acme.com"
)

# In a batch the attribute is one per call and the value rides on the item, as
# matching_value or as the attribute itself. The two forms mix.
client.records.bulk_upsert("person", "email", records=[
    {"matching_value": "jane@acme.com", "first_name": "Jane", "last_name": "Doe"},
    {"emails": ["sam@globex.com"], "first_name": "Sam", "last_name": "Rivera"},
])
```

### Filtering with attributes

```python
attrs = client.attributes.list(record_type="company").data
# Scope to a list to see the custom fields that belong to it. A list you
# cannot open answers 404 not_found, the same as one that does not exist.
scoped = client.attributes.list(record_type="company", list_id="…uuid…").data
# pick a field permalink from attrs, then:
client.records.search(
    "company",
    filters={
        "operator": "AND",
        "conditions": [{"field": "headcount", "operator": "gte", "value": 50}],
    },
    sort=[{"field": "name", "direction": "asc"}],
    limit=25,
)
```

### Lists

```python
new_list = client.lists.create(name="Hot leads", record_type="company")
list_id = new_list.data["id"]

# Adding a record that is already on the list, and removing one that is not,
# both succeed and write nothing. `reason` is what tells that apart from a real
# change: a sentence when nothing changed, None when something did. It is on
# the single writes and on every entry of the two bulk ones, where a failed
# entry carries the refusal in words there instead.
added = client.lists.add_record(list_id, record_id="…uuid…")
if added.data.get("reason"):
    print(added.data["reason"])  # "This record was already on the list."
client.lists.get_records(list_id, limit=50)
```

### Sharing views and dashboards

```python
client.views.update_sharing("…view_uuid…", "workspace", share_along=True)
client.views.share_with_teams("…view_uuid…", ["…team_uuid…"], share_along=True)

client.dashboards.update("…dashboard_uuid…", sharing="workspace", share_along=True)
client.dashboards.share_with_teams("…dashboard_uuid…", ["…team_uuid…"], share_along=True)
```

These four calls always answer `data["shared_along"]` (what the call shared along, empty when nothing was) and `data["audience_cannot_open"]`. `audience_cannot_open` lists the lists the view filters on, or the dashboard's widgets filter on, that some of the people it is now shared with cannot open. For those people a filter on such a list is not applied. Only lists you can open are listed. An entry with `is_parent` true is the list a list view belongs to: people who cannot open it do not see the view at all.

`share_along` also shares the lists you own with the same people. Every one of them is shared along, so read `audience_cannot_open` first if you want to choose. A list is shared along only when your role can share lists. Nothing is shared along unless you pass `True`. `revoke_teams` never shares anything along and ignores the option.

### Notes

```python
client.notes.create(
    title="Intro call",
    content="<p>Met the founder, looking strong.</p>",
    records={"company": ["…uuid…"], "person": ["…uuid…"]},
    tags=["follow_up"],
)

# Manage the workspace tag vocabulary and attach tags to a note.
tag = client.note_tags.create("Follow up")
client.notes.add_tags("…note_uuid…", tags=[tag.data["permalink"]])
```

A note now reads back who it mentions: `tagged_users` is a list of the member
object (`{"id", "first_name", "last_name", "full_name", "avatar_url", "email"}`),
`[]` when it mentions nobody. It is read from the mention spans in `content`,
not from the ids a write sent, so pair every id in `tagged_users` with its span
(`<span data-type="mention" data-id="…" data-label="First Last">@First Last</span>`)
as the reference says, or the note reads back `[]`.

### Tasks

```python
client.tasks.create(
    title="Send follow-up",
    due_date="2026-06-01",
    assigned_to=["…user_uuid…"],
    records={"company": ["…uuid…"]},
)
```

`status` and `priority` answer the stored value and nothing else. The words
your workspace puts on those values come from `options.list()` for the `task`
record type, one call that covers every task.

`tasks.list()` filters and sorts on these fields: `title`, `status`,
`priority`, `due_date`, `created_at`, `updated_at`, `assigned_to`,
`created_by`, `records`, plus any custom task field permalinks. Status values
are workspace-specific (fetch the valid values with
`client.options.list("task", "status")`). Each option row is `{ value, label, color,
priority, is_default, enabled }`, the same object an attribute's
`options[]` carries; a built-in value the workspace switched off is listed
with `enabled: false`, so filter on `enabled` before offering one.

```python
client.tasks.list(
    filters={
        "operator": "AND",
        "conditions": [{"field": "status", "operator": "eq", "value": "todo"}],
    },
    sort=[{"field": "due_date", "direction": "asc"}],
    limit=50,
)
```

### Discovery

```python
# Always inspect attributes first to discover valid field keys
client.discovery.get_attributes(record_type="company")

# Search Lighthouse's global database
res = client.discovery.search_companies(
    filters={
        "operator": "AND",
        "conditions": [
            {"field": "company_hq_country", "operator": "contains_any", "value": ["US"]},
            {"field": "company_headcount", "operator": "between", "value": {"min": 10, "max": 200}},
        ],
    },
    limit=25,
)

# Enrich by domain / LinkedIn
client.discovery.lookup_companies_by_domain(["stripe.com", "openai.com"])

# Find an entity by name (typo-tolerant, ranked by relevance then popularity).
# record_type is one of company, person, investor, school. Use a returned id in
# a filter, or pass it to a deep read. Pages with limit / offset and reports
# meta["has_more"].
matches = client.discovery.search_entities(
    record_type="company", query="relay robotics", limit=10
)

# Resolve a name straight to the single best match: the full profile for a
# company or a person, the identity row for an investor or a school. 404 when
# nothing matches, so prefer search_entities when the name is ambiguous.
company = client.discovery.get_entity_by_name(
    record_type="company", name="Relay Robotics"
)

# Semantic search with a plain-language query (optionally narrowed by filters)
res = client.discovery.semantic_search_companies(
    "climate fintech startups building carbon accounting software",
    filters={
        "operator": "AND",
        "conditions": [
            {"field": "company_hq_country", "operator": "contains_any", "value": ["US"]},
        ],
    },
    min_similarity=0.5,
    limit=25,
)
```

`search_investors`, `search_organizations` and `search_schools` are deprecated:
use `search_entities` with `record_type` `"investor"`, `"company"` or
`"school"` instead (typo-tolerant ranking, paging, and richer matches). They
keep working.

The shared vocabularies (`get_locations`, `get_industries`, `get_tags`,
`get_funding_types`) answer `value` (what a filter takes) beside `label` (what
to show someone). `name` carries the same string as `value` on funding types and
the same string as `label` on the other three; it is kept for compatibility, so
read `value` and `label`.

A discovery person's `associated_companies` and `education` entries carry `id`,
the company or school id `get_company` takes. `org_id` holds the same value and
is kept for compatibility.

Every discovery company and person row carries `crm_id`: the id of your own CRM
record for that entity, and `None` when it is not in your CRM. It is on the
search pages, the two lookups, the saved-search results and the full profiles
alike, so "which of these do we already track" is answered by the row you have
rather than by a search per result. Pass it to
`client.records.get("company", crm_id)` to open the record, and use
`client.records.upsert` on the rows where it is `None` to bring one in without
creating a duplicate. It is the same name, with the same meaning, that a CRM
record already uses when it points at the global database (a company's
`investors`, a person's `experience` and `education`).

### Documents

```python
# One-shot helper handles upload-url + the multipart POST upload
with open("pitch.pdf", "rb") as f:
    file = client.documents.upload(
        name="pitch.pdf",
        content=f.read(),
        content_type="application/pdf",
        sharing="workspace",
    )
file_id = file.data["id"]

# Attach to a record
client.documents.link_file_to_record(file_id, "company", "…uuid…")
```

## Error handling

```python
from lighthouse import Lighthouse, LighthouseError

client = Lighthouse("lgt_live_…")
try:
    res = client.records.search("company")
    res.raise_for_error()
    print(res.data)
except LighthouseError as e:
    print(f"API error ({e.status_code}):", e)
```

## Low-level escape hatch

For endpoints not yet wrapped by a namespace:

```python
res = client.request("GET", "/v1/some/new/endpoint", params={"foo": "bar"})
res = client.request("POST", "/v1/some/other", json={"hello": "world"})
```

## License

MIT, see [LICENSE](LICENSE).
