@villagehq/sdk 0.1.0-beta.0 → 0.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +455 -0
- package/README.md +20 -240
- package/bin/village-sdk.d.mts +15 -0
- package/bin/village-sdk.mjs +382 -0
- package/dist/core/api-types.d.ts +7 -0
- package/dist/core/api-types.js +1 -0
- package/dist/core/connect-popup.d.ts +8 -0
- package/dist/core/connect-popup.js +85 -0
- package/dist/core/contract-types.d.ts +41 -0
- package/dist/core/contract-types.js +1 -0
- package/dist/core/errors.d.ts +6 -2
- package/dist/core/errors.js +33 -10
- package/dist/core/paths-stores.d.ts +86 -0
- package/dist/core/paths-stores.js +132 -0
- package/dist/core/session.d.ts +20 -0
- package/dist/core/session.js +121 -0
- package/dist/core/store.d.ts +18 -0
- package/dist/core/store.js +56 -0
- package/dist/core/transport.d.ts +4 -2
- package/dist/core/transport.js +8 -3
- package/dist/core/user.d.ts +15 -30
- package/dist/core/user.js +17 -4
- package/dist/core/village.d.ts +63 -39
- package/dist/core/village.js +199 -15
- package/dist/elements/elements.d.ts +31 -0
- package/dist/elements/elements.js +303 -0
- package/dist/generated/api.d.ts +1074 -46
- package/dist/react/react.d.ts +39 -28
- package/dist/react/react.js +12 -8
- package/dist/server/server.d.ts +63 -36
- package/dist/server/server.js +13 -2
- package/package.json +26 -11
package/AGENTS.md
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
# Build on Village
|
|
2
|
+
|
|
3
|
+
Put warm introductions in your product: who your users know at a company, and who can introduce them. This page is the whole integration, written for AI coding agents and the developers working with them. Follow it top to bottom.
|
|
4
|
+
|
|
5
|
+
- Package: `@villagehq/sdk` (SDK version `0.2.0-beta.1`).
|
|
6
|
+
- Published at https://docs.village.ai/build (raw: https://docs.village.ai/build.md). The copy in `node_modules/@villagehq/sdk/AGENTS.md` matches your installed version.
|
|
7
|
+
|
|
8
|
+
## 1. Quick start
|
|
9
|
+
|
|
10
|
+
Build and verify locally first. No Village account, human approval, card, or real network is needed until Going live.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @villagehq/sdk@0.2.0-beta.1
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use the demo keys below and follow the chosen mode's code example. If billing is not specified yet, start with the payer `user` demo while asking who pays; local verification can continue without a human response. Render `<village-connect>` and `<village-facepile company="stripe.com">`, click Connect, and verify the synthetic result.
|
|
17
|
+
|
|
18
|
+
**Rules for coding agents:** use only the APIs documented on this page. Do not invent endpoints or keys. Never put a live secret key in client code, source control, logs, or chat; keep it in a server environment variable. Publishable keys are public. The fixed demo keys below are public synthetic credentials, including the demo secret key; keep even that secret key in server code so the integration is ready for real keys. Follow the payer choice in the requirements. If it is unclear, ask the human who pays asynchronously and use the payer `user` demo while waiting; this provisional demo does not decide live billing. Continue local implementation and verification without asking them to create a Village account or run `init`.
|
|
19
|
+
|
|
20
|
+
## 2. Public demo keys
|
|
21
|
+
|
|
22
|
+
These exact keys work on HTTP localhost and `127.0.0.1` at any port. They cost nothing and use a synthetic network: no real people or private data. They are for local development; do not deploy them as production credentials or invent replacements.
|
|
23
|
+
|
|
24
|
+
| Payer | Publishable key | Server secret key |
|
|
25
|
+
| ------ | --------------------------- | -------------------------- |
|
|
26
|
+
| `user` | `pk_test_village_demo_user` | None |
|
|
27
|
+
| `app` | `pk_test_village_demo_app` | `sk_test_village_demo_app` |
|
|
28
|
+
|
|
29
|
+
For payer `user`, put this in `.env.local`:
|
|
30
|
+
|
|
31
|
+
```dotenv
|
|
32
|
+
NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY=pk_test_village_demo_user
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For payer `app`, use:
|
|
36
|
+
|
|
37
|
+
```dotenv
|
|
38
|
+
NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY=pk_test_village_demo_app
|
|
39
|
+
VILLAGE_SECRET_KEY=sk_test_village_demo_app
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Keep `.env.local` in `.gitignore`. Restart your dev server after changing it. Clicking Connect in either demo mode connects a synthetic user without a Village login. The test network normally turns `ready` about 5 seconds later.
|
|
43
|
+
|
|
44
|
+
## 3. Who pays
|
|
45
|
+
|
|
46
|
+
If the requirements already say who pays, follow that choice. Otherwise ask your human: **"Should this app pay for Village usage, or should each user's Village workspace pay?"** Ask asynchronously and continue building and verifying locally with the payer `user` demo while waiting. Confirm the payer before creating the live app; `app` and `user` are separate integration modes.
|
|
47
|
+
|
|
48
|
+
If the app pays, use **payer `app`**. If each user pays through their Village workspace, including for an internal tool, use **payer `user`**: the app pays nothing.
|
|
49
|
+
|
|
50
|
+
| | payer `user` | payer `app` |
|
|
51
|
+
| --------------------------------- | ----------------------------------------- | ---------------------------------- |
|
|
52
|
+
| Live sign-in | The user signs in with Village in a popup | Your server mints a token per user |
|
|
53
|
+
| Keys you hold | Publishable key only | Publishable key + secret key |
|
|
54
|
+
| Your server can read Village data | No | Yes |
|
|
55
|
+
| Card needed for live keys | No | Yes |
|
|
56
|
+
|
|
57
|
+
## 4. Code
|
|
58
|
+
|
|
59
|
+
### Payer `user`, Next.js App Router
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// lib/village.ts
|
|
63
|
+
'use client';
|
|
64
|
+
|
|
65
|
+
import { createVillage } from '@villagehq/sdk';
|
|
66
|
+
import { defineVillageElements } from '@villagehq/sdk/elements';
|
|
67
|
+
|
|
68
|
+
export const village = createVillage({
|
|
69
|
+
publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
defineVillageElements(village); // does nothing on the server
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
// components/warm-intros.tsx
|
|
77
|
+
'use client';
|
|
78
|
+
|
|
79
|
+
import type {} from '@villagehq/sdk/react'; // JSX types for <village-*>
|
|
80
|
+
import '@/lib/village'; // registers the elements
|
|
81
|
+
|
|
82
|
+
export function WarmIntros({ company }: { company: string }) {
|
|
83
|
+
return (
|
|
84
|
+
<section>
|
|
85
|
+
<village-connect />
|
|
86
|
+
<village-facepile company={company} />
|
|
87
|
+
<village-paths company={company} onvillage-path-select={(event) => askForIntro(event.detail)} />
|
|
88
|
+
</section>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Import `village` from `lib/village` wherever you need `village.connect()`, `village.signOut()` or a store. That's the whole integration: no server code. Sessions live in `localStorage` (`village.session.<publishableKey>`), slide for 30 days, and renew on their own. `village.signOut()` disconnects the app; call it when your user signs out.
|
|
94
|
+
|
|
95
|
+
### Payer `app`, Next.js App Router
|
|
96
|
+
|
|
97
|
+
Your server mints a Village token for your signed-in user. For the local demo, use a synthetic app user from your development auth; a Village account is not required:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// app/api/village/token/route.ts
|
|
101
|
+
import { createVillageApp } from '@villagehq/sdk/server';
|
|
102
|
+
|
|
103
|
+
const villageApp = createVillageApp({
|
|
104
|
+
secretKey: process.env.VILLAGE_SECRET_KEY ?? '',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export async function POST(request: Request) {
|
|
108
|
+
const user = await requireSignedInUser(request); // your auth: return 401 when signed out
|
|
109
|
+
const { token } = await villageApp.tokens.create({ id: user.id, email: user.email });
|
|
110
|
+
return Response.json({ token });
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The browser asks your route for it. Create the client per signed-in user:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
'use client';
|
|
118
|
+
|
|
119
|
+
import { createVillage } from '@villagehq/sdk';
|
|
120
|
+
import { defineVillageElements } from '@villagehq/sdk/elements';
|
|
121
|
+
import type {} from '@villagehq/sdk/react';
|
|
122
|
+
import { useState } from 'react';
|
|
123
|
+
|
|
124
|
+
async function readVillageToken() {
|
|
125
|
+
const response = await fetch('/api/village/token', { method: 'POST' });
|
|
126
|
+
if (!response.ok) throw new Error(`Village token route answered ${response.status}`);
|
|
127
|
+
const { token } = await response.json();
|
|
128
|
+
return token ?? '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createUserVillage() {
|
|
132
|
+
const village = createVillage({
|
|
133
|
+
publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
|
|
134
|
+
token: readVillageToken,
|
|
135
|
+
});
|
|
136
|
+
defineVillageElements(village);
|
|
137
|
+
return village;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Render with key={user.id}, so another user gets another client.
|
|
141
|
+
export function WarmIntros({ company }: { company: string }) {
|
|
142
|
+
useState(createUserVillage); // one client for this user, created once
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<village-connect />
|
|
146
|
+
<village-facepile company={company} />
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The SDK calls `token` once, then refreshes it and retries once after a 401 mapped to `unauthorized`. `village.connect()` opens Village's network-sync modal; with test keys it connects the test user instead, with no modal. `secretKey` must never reach the browser: no `NEXT_PUBLIC_` prefix, and `createVillageApp` throws if it runs in one.
|
|
153
|
+
|
|
154
|
+
### Plain HTML, no build step
|
|
155
|
+
|
|
156
|
+
```html
|
|
157
|
+
<script type="module">
|
|
158
|
+
import { createVillage } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.1';
|
|
159
|
+
import { defineVillageElements } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.1/elements';
|
|
160
|
+
|
|
161
|
+
const village = createVillage({ publishableKey: 'pk_test_village_demo_user' }); // a publishable key is public
|
|
162
|
+
defineVillageElements(village);
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<village-connect></village-connect>
|
|
166
|
+
<village-facepile company="stripe.com"><span slot="empty">No warm intros yet</span></village-facepile>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Pin the version in the URL.
|
|
170
|
+
|
|
171
|
+
### Server only (payer `app`)
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { createVillageApp } from '@villagehq/sdk/server';
|
|
175
|
+
|
|
176
|
+
const villageApp = createVillageApp({ secretKey: process.env.VILLAGE_SECRET_KEY ?? '' });
|
|
177
|
+
const member = villageApp.as({ id: user.id, email: user.email });
|
|
178
|
+
|
|
179
|
+
const { status } = await member.user.get();
|
|
180
|
+
if (status === 'ready' || status === 'needs_attention') {
|
|
181
|
+
const page = await member.companies.paths('stripe.com', { degrees: [1, 2], limit: 10 });
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
With real keys, the user still connects once in a browser (`village.connect()`), because only they can sync their network. `await member.user.ready({ timeoutMs })` waits for it in a job. Payer `user` apps have no server access.
|
|
186
|
+
|
|
187
|
+
## 5. Verify it works
|
|
188
|
+
|
|
189
|
+
Run the actual page on localhost with the public demo keys before you say you're done:
|
|
190
|
+
|
|
191
|
+
- [ ] The page renders `<village-connect>` with `state="signed_out"` (payer `user`) or `state="not_connected"` (payer `app`). Check the attribute in the DOM.
|
|
192
|
+
- [ ] Clicking it connects:
|
|
193
|
+
- payer `user`: the synthetic user connects without a Village login or account;
|
|
194
|
+
- payer `app`: with test keys, no modal opens: the test user connects at once. (On your server, `villageApp.test.connectUser({ id, email })` does the same; the page notices on its next `village.user.get()` or reload.)
|
|
195
|
+
- [ ] `<village-connect>` goes `syncing`, then `ready` within about 10 s.
|
|
196
|
+
- [ ] No Village account, human approval, card, or real network sync was needed for this demo.
|
|
197
|
+
- [ ] `<village-facepile company="stripe.com">` ends in `found` or `none`. Try 5 domains: the test network has paths for about 70% of them.
|
|
198
|
+
- [ ] Clicking an item in `<village-paths>` fires `village-path-select` with a `person`.
|
|
199
|
+
- [ ] Payer `user`: after `village.signOut()`, the elements go back to `signed_out`.
|
|
200
|
+
- [ ] The browser console has no CSP errors (see the reference) and no `VillageError`.
|
|
201
|
+
- [ ] `VILLAGE_SECRET_KEY` appears only in server code, and `.env.local` is in `.gitignore`.
|
|
202
|
+
|
|
203
|
+
A script check for payer `app`:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { createVillageApp } from '@villagehq/sdk/server';
|
|
207
|
+
|
|
208
|
+
const villageApp = createVillageApp({ secretKey: process.env.VILLAGE_SECRET_KEY ?? '' });
|
|
209
|
+
const user = { id: 'test-user-1', email: 'test@example.com' };
|
|
210
|
+
await villageApp.test.connectUser(user);
|
|
211
|
+
const member = villageApp.as(user);
|
|
212
|
+
await member.user.ready({ timeoutMs: 30_000 });
|
|
213
|
+
console.log(await member.companies.checkPaths('stripe.com'));
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## 6. Errors
|
|
217
|
+
|
|
218
|
+
Wrapped SDK calls throw `VillageError` with `code`, `message`, `status`, `requestId`, `traceId` and `docsUrl` (a link to the row below). Caller cancellation can throw `AbortError`; raw `village.api` calls use the OpenAPI client response/error contract. `status`, `requestId`, `traceId`, `payer`, `topupUrl` and `syncUrl` can be null. `isVillageError(error, 'out_of_credits')` narrows it. `error.isExpected` is `true` for states your UI should show rather than report; `error.isRetryable` for transient failures; the flag does not retry a request.
|
|
219
|
+
|
|
220
|
+
| `code` | Cause | Fix |
|
|
221
|
+
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
|
222
|
+
| <a id="errors-signed_out"></a>`signed_out` | Payer `user`: no session in this browser | Render a button that calls `village.connect()` |
|
|
223
|
+
| <a id="errors-app_access_revoked"></a>`app_access_revoked` | The user removed your app from Village | Same as `signed_out`: the SDK already cleared the session |
|
|
224
|
+
| <a id="errors-not_connected"></a>`not_connected` | Payer `app`: the user hasn't linked Village | Call `village.connect()` from a click |
|
|
225
|
+
| <a id="errors-not_synced"></a>`not_synced` | The network has not been synced, or sync is unfinished | Connect/sync if needed, then show progress; use `error.syncUrl` when present |
|
|
226
|
+
| <a id="errors-out_of_credits"></a>`out_of_credits` | Out of credits. `error.payer` says whose when provided | `user`: link `error.topupUrl` if present. `app`: top up in the dashboard; payer can be null |
|
|
227
|
+
| <a id="errors-unauthorized"></a>`unauthorized` | Invalid key or token, or the app is inactive (live keys need a card for payer `app`) | `user`: connect again. `app`: check keys, token route and live billing; callback tokens retry once |
|
|
228
|
+
| <a id="errors-user_required"></a>`user_required` | A payer `user` app called Village without a signed-in user | Call `village.connect()` first; don't mint tokens for payer `user` apps |
|
|
229
|
+
| <a id="errors-test_mode_unsupported"></a>`test_mode_unsupported` | Test keys don't cover this endpoint | Use the endpoints in this guide, or live keys |
|
|
230
|
+
| <a id="errors-forbidden"></a>`forbidden` | The key or token can't do this | Read `error.message`; changing payer needs support once users exist |
|
|
231
|
+
| <a id="errors-popup_blocked"></a>`popup_blocked` | The browser blocked the sign-in popup | Call `village.connect()` directly in a click handler, not after an `await` |
|
|
232
|
+
| <a id="errors-token_failed"></a>`token_failed` | Your `token` function threw | Check your token route; `error.cause` has the original error |
|
|
233
|
+
| <a id="errors-validation"></a>`validation` | The request is invalid | See `error.fields` |
|
|
234
|
+
| <a id="errors-bad_request"></a>`bad_request` | The API refused the request, or the SDK was misconfigured | Read `error.message` |
|
|
235
|
+
| <a id="errors-not_found"></a>`not_found` | The requested resource was not found | Initial `paths()` lookups return `null`; otherwise handle the missing resource |
|
|
236
|
+
| <a id="errors-conflict"></a>`conflict` | The request conflicts with the current state | Read `error.message` and resolve the conflict |
|
|
237
|
+
| <a id="errors-rate_limited"></a>`rate_limited` | Too many requests | Back off and retry |
|
|
238
|
+
| <a id="errors-server"></a>`server` | Village failed | Retry later; send `requestId` to support |
|
|
239
|
+
| <a id="errors-timeout"></a>`timeout` | Village took too long (searches can take a minute) | Retry, or pass a larger `{ timeoutMs }` |
|
|
240
|
+
| <a id="errors-network"></a>`network` | Couldn't reach Village | Check connectivity and CSP `connect-src` |
|
|
241
|
+
| <a id="errors-invalid_response"></a>`invalid_response` | Village returned something the SDK couldn't read | Update the SDK; report `requestId` |
|
|
242
|
+
|
|
243
|
+
## 7. Going live
|
|
244
|
+
|
|
245
|
+
After the local demo passes, create your own Village app:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npx @villagehq/sdk@0.2.0-beta.1 init --name "Acme" --payer user # or --payer app
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`init` defaults to `http://localhost:3000`. If your local app uses another port, pass its actual origin, for example `--origin http://localhost:3010`.
|
|
252
|
+
|
|
253
|
+
This is the first step that needs a human with a Village account. `init` prints and opens an approval URL; ask the human to sign in there and approve the app. It writes your app's test keys to `.env.local`, runs a smoke test, and prints next steps and dashboard links without printing keys. If replacing the demo keys already in `.env.local`, `--yes` allows replacement without a terminal prompt.
|
|
254
|
+
|
|
255
|
+
In the Village dashboard at https://village.ai/platform, register and approve the app's HTTPS deployment origin before using live keys. Payer `app` also needs a card added at the billing link from `init`; payer `user` needs no app card. Use your deployment's secret/environment settings to replace test keys with the app's live publishable key and, for payer `app`, live server secret key. Never request or paste live secrets in chat.
|
|
256
|
+
|
|
257
|
+
Live keys require an approved HTTPS origin; they do not work on HTTP localhost. Live users connect and sync their real networks: payer `user` signs in through the Village popup, while payer `app` uses your signed-in user's id and email and opens the network-sync modal. Verify the deployed flow from the approved origin before launch.
|
|
258
|
+
|
|
259
|
+
CLI flags: `--name`, `--origin`, `--payer app|user`, `--yes` (`-y`), `--help` (`-h`).
|
|
260
|
+
|
|
261
|
+
## 8. Reference
|
|
262
|
+
|
|
263
|
+
### User states
|
|
264
|
+
|
|
265
|
+
`village.user.get()` or `village.user.subscribe(({ user, error }) => …)`; `user.status` is one of:
|
|
266
|
+
|
|
267
|
+
| `status` | Meaning | Render |
|
|
268
|
+
| ----------------- | ---------------------------------------------------- | --------------------------------------------------- |
|
|
269
|
+
| `signed_out` | Payer `user`: nobody signed in, or the session ended | A button that calls `village.connect()` |
|
|
270
|
+
| `not_connected` | Payer `app`: no Village network linked yet | A button that calls `village.connect()` |
|
|
271
|
+
| `syncing` | Importing the network; paths come soon | Progress |
|
|
272
|
+
| `ready` | Everything works | The feature |
|
|
273
|
+
| `needs_attention` | Works on older data; an integration needs re-auth | The feature, plus "Reconnect" → `village.connect()` |
|
|
274
|
+
|
|
275
|
+
`isNetworkReady(user)` is `true` for `ready` and `needs_attention`. Call `connect()` from a click: browsers block popups otherwise.
|
|
276
|
+
|
|
277
|
+
### Elements
|
|
278
|
+
|
|
279
|
+
Register once with `defineVillageElements(village)` (from `@villagehq/sdk/elements`).
|
|
280
|
+
|
|
281
|
+
| Element | Attributes | Shows |
|
|
282
|
+
| -------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
283
|
+
| `<village-connect>` | none | The connect button, sync progress, or its default slot when ready (fallback text "Village connected") |
|
|
284
|
+
| `<village-facepile>` | `company` (domain, URL or LinkedIn company URL) | Avatars of people who can introduce you, `+N`, and the strength label |
|
|
285
|
+
| `<village-paths>` | `company` or `person` (LinkedIn URL or slug), `limit` (default 5) | A list of introducers; each is a button |
|
|
286
|
+
|
|
287
|
+
Every element sets `state` and `data-state` to its current state: `loading`, `signed_out`, `not_connected`, `not_synced`, `out_of_credits`, `none`, `found`, `error` (`<village-connect>` uses the user statuses). Its root is an `aria-live` region; images carry the person's name as `alt`.
|
|
288
|
+
|
|
289
|
+
**Customize**, in increasing control:
|
|
290
|
+
|
|
291
|
+
```css
|
|
292
|
+
village-facepile {
|
|
293
|
+
--village-avatar-size: 32px;
|
|
294
|
+
--village-accent: #0f766e; /* buttons, initials, focus ring */
|
|
295
|
+
--village-radius: 6px; /* buttons and list items */
|
|
296
|
+
--village-font: inherit; /* the default */
|
|
297
|
+
}
|
|
298
|
+
village-facepile::part(avatar) {
|
|
299
|
+
border-radius: 6px;
|
|
300
|
+
}
|
|
301
|
+
village-paths::part(item):hover {
|
|
302
|
+
background: #f4f4f5;
|
|
303
|
+
}
|
|
304
|
+
village-facepile[state='none'] {
|
|
305
|
+
display: none;
|
|
306
|
+
}
|
|
307
|
+
village-facepile:not(:defined) {
|
|
308
|
+
display: inline-block;
|
|
309
|
+
min-height: 32px;
|
|
310
|
+
} /* no layout shift */
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Parts: `root`, `avatar`, `more`, `label`, `button`, `item`, `name`, `reason`.
|
|
314
|
+
|
|
315
|
+
Slots (replace the default content):
|
|
316
|
+
|
|
317
|
+
| Slot | When |
|
|
318
|
+
| ------------ | ------------------------------------------------------------ |
|
|
319
|
+
| `loading` | `loading` |
|
|
320
|
+
| `signed-out` | `signed_out` and `not_connected` (default: a connect button) |
|
|
321
|
+
| `action` | The connect button's label |
|
|
322
|
+
| `empty` | `none` |
|
|
323
|
+
|
|
324
|
+
Events (bubbling, composed `CustomEvent`s):
|
|
325
|
+
|
|
326
|
+
| Event | `detail` |
|
|
327
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
328
|
+
| `village-path-select` | `{ person, introducer, degree, reason }`; `introducer` is `null` for someone the user knows directly. Each person is `{ name, initials, avatar_url, linkedin_url }` |
|
|
329
|
+
| `village-connected` | `{ user }`, after `connect()` leaves the user connected (not when they close the popup or modal without connecting) |
|
|
330
|
+
| `village-error` | `{ error }`, when `connect()` fails, e.g. `popup_blocked` |
|
|
331
|
+
|
|
332
|
+
**React 19 + Tailwind.** `import type {} from '@villagehq/sdk/react'` adds the JSX types. Events are `on` + the event name:
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
<village-facepile
|
|
336
|
+
company="stripe.com"
|
|
337
|
+
className="[--village-accent:var(--color-teal-700)] [&::part(avatar)]:rounded-md"
|
|
338
|
+
onvillage-error={(event) => console.error(event.detail.error)}
|
|
339
|
+
>
|
|
340
|
+
<span slot="empty" className="text-sm text-muted-foreground">
|
|
341
|
+
No warm intros yet
|
|
342
|
+
</span>
|
|
343
|
+
</village-facepile>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
React 18 can't listen to custom element events; use the stores below. Elements render in the browser only, which is fine: this data is per user.
|
|
347
|
+
|
|
348
|
+
### Stores, for your own UI
|
|
349
|
+
|
|
350
|
+
Every element is built on a store. Use them for fully custom UI in any framework.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
const intros = village.paths.check('stripe.com'); // facepile data
|
|
354
|
+
const paths = village.paths.company('stripe.com', { limit: 10 });
|
|
355
|
+
const personPaths = village.paths.person('linkedin.com/in/satyanadella');
|
|
356
|
+
|
|
357
|
+
const stop = intros.subscribe((state) => render(state)); // called right away, then on every change
|
|
358
|
+
intros.get(); // the current state, same object until it changes
|
|
359
|
+
intros.refresh(); // fetch again
|
|
360
|
+
stop();
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
type PathsCheckState =
|
|
365
|
+
| { kind: 'loading' }
|
|
366
|
+
| { kind: 'signed_out' }
|
|
367
|
+
| { kind: 'not_connected' }
|
|
368
|
+
| { kind: 'not_synced' }
|
|
369
|
+
| { kind: 'out_of_credits'; payer: 'app' | 'user'; topupUrl: string | null }
|
|
370
|
+
| { kind: 'none' }
|
|
371
|
+
| { kind: 'found'; people: FacepilePerson[]; more: number; strength: number | null; strength_label: string | null }
|
|
372
|
+
| { kind: 'error'; error: VillageError };
|
|
373
|
+
// paths.person / paths.company: the same, with { kind: 'found'; paths } instead.
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
What the stores do for you:
|
|
377
|
+
|
|
378
|
+
- **Shared**: the same company returns the same store, so 50 badges make one fetch.
|
|
379
|
+
- **Batched**: every `check` asked for in the same tick goes out as one request (100 companies each).
|
|
380
|
+
- **Credit-safe**: a store fetches on its first subscriber and keeps its result after the last one leaves. It never retries on its own; `refresh()` does.
|
|
381
|
+
- **Live**: a `not_synced` store fetches again when the network turns ready; every store resets on sign-out.
|
|
382
|
+
|
|
383
|
+
React:
|
|
384
|
+
|
|
385
|
+
```tsx
|
|
386
|
+
import { useVillage } from '@villagehq/sdk/react';
|
|
387
|
+
|
|
388
|
+
function IntroBadge({ company }: { company: string }) {
|
|
389
|
+
const state = useVillage(village.paths.check(company));
|
|
390
|
+
if (state.kind === 'found') return <span>{state.people.length + state.more} warm intros</span>;
|
|
391
|
+
if (state.kind === 'signed_out' || state.kind === 'not_connected') return <ConnectButton />;
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Also `useVillageUser(village)` → `{ user, status, isLoading, error, refresh }`, `useVillageConnect(village)` → `{ connect, isConnecting }`, and `<VillageGate village fallback>`.
|
|
397
|
+
|
|
398
|
+
Vue:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
import { onScopeDispose, shallowRef } from 'vue';
|
|
402
|
+
import type { VillageStore } from '@villagehq/sdk';
|
|
403
|
+
|
|
404
|
+
export function useVillageStore<State>(store: VillageStore<State>) {
|
|
405
|
+
const state = shallowRef(store.get());
|
|
406
|
+
onScopeDispose(store.subscribe((next) => (state.value = next)));
|
|
407
|
+
return state;
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Svelte (stores follow the Svelte store contract):
|
|
412
|
+
|
|
413
|
+
```svelte
|
|
414
|
+
<script>
|
|
415
|
+
const intros = village.paths.check('stripe.com');
|
|
416
|
+
</script>
|
|
417
|
+
{#if $intros.kind === 'found'}{$intros.strength_label}{/if}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Vanilla:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
village.paths.check('stripe.com').subscribe((state) => {
|
|
424
|
+
badge.dataset.state = state.kind;
|
|
425
|
+
badge.textContent = state.kind === 'found' ? `${state.people.length + state.more} warm intros` : '';
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Other calls
|
|
430
|
+
|
|
431
|
+
Wrapped calls return a result or throw `VillageError` (caller cancellation can throw `AbortError`):
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
await village.people.search('CTOs in Berlin'); // a Page: results, hasMore, next(), for await
|
|
435
|
+
await village.companies.search({ prompt: 'fintech in NYC', scope: 'global' });
|
|
436
|
+
await village.people.paths('satyanadella'); // null when Village doesn't know them
|
|
437
|
+
await village.companies.paths('stripe.com', { degrees: [2, 3] });
|
|
438
|
+
await village.people.enrich('satyanadella');
|
|
439
|
+
await village.companies.enrich('stripe.com');
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Raw `village.api` access is typed but does not wrap failures as `VillageError`. Coding agents should use the calls shown on this page for this integration.
|
|
443
|
+
|
|
444
|
+
Calls that spend credits shouldn't retry on their own; in React Query that's `staleTime: Infinity, retry: false`. `createVillage({ onRequest })` reports every request (`operation`, `status`, `durationMs`, `requestId`, `error`) for your monitoring.
|
|
445
|
+
|
|
446
|
+
### Content Security Policy and popups
|
|
447
|
+
|
|
448
|
+
```
|
|
449
|
+
connect-src https://api.village.ai;
|
|
450
|
+
img-src https:; # avatars come from several CDNs
|
|
451
|
+
frame-src https://village.ai; # payer app: the sync modal
|
|
452
|
+
script-src https://esm.sh; # only for the CDN import
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The payer `user` sign-in is a popup on `https://village.ai/connect`. If your site sends `Cross-Origin-Opener-Policy: same-origin`, the popup can't report back: use `same-origin-allow-popups`.
|