Javis OS
Javis OS는 기업가를 돕는 에이전시 AI로, 업무를 지원하고 함께 성장합니다
Clandar는 가계·사업용 지출·청구서 관리, 프로젝트 예산·일정 관리, 영수증·알림 등 업무를 AI 비서가 처리해 줍니다
For your household or your business: Clandar tracks spending and bills, keeps every project on budget and on schedule, and gives you an AI assistant that handles the busywork — receipts, reminders, bu
비즈니스TypeScript · PLpgSQLGitHub @xnjgroup온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기For your household or your business: Clandar tracks spending and bills, keeps every project on budget and on schedule, and gives you an AI assistant that handles the busywork — receipts, reminders, budgets and follow-ups.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A multi-tenant business dashboard for any small business: customers, projects, AI-assisted photo
quoting, scheduling, a task/shopping/permit list, the Clandar assistant chat that can act on your
data, and a Gmail-backed customer-support inbox — plus the invoice extraction, approvals, budgets
and spend-analytics module the app started as.
Every page reads from a local Postgres database — there is no fixture data in the app code.
What's shipped, in progress and next is in doc/ROADMAP.md; the architecture is
in doc/DESIGN.md.README 전문 보기 ↗A multi-tenant business dashboard for any small business: customers, projects, AI-assisted photo quoting, scheduling, a task/shopping/permit list, the Clandar assistant chat that can act on your data, and a Gmail-backed customer-support inbox — plus the invoice extraction, approvals, budgets and spend-analytics module the app started as. Every page reads from a local Postgres database — there is no fixture data in the app code.
What's shipped, in progress and next is in doc/ROADMAP.md; the architecture is
in doc/DESIGN.md.
The app expects a local Postgres. If you are already running the clandar-postgres
container, it is ready to use:
docker run -d --name clandar-postgres -p 5432:5432 \
-e POSTGRES_USER=clandar -e POSTGRES_PASSWORD=clandar_dev -e POSTGRES_DB=clandar \
pgvector/pgvector:pg17
Gmail scans and bulk trash, reminders, the lead finder and scheduled automations run as jobs on a
queue in Postgres (background_jobs, lib/jobs.ts) — no Redis. A runner claims and runs them:
npm run dev / npm start runs one in-process (set RUNNER=off to skip it);npm run runner runs one anywhere with the app's env — your laptop, a small always-on box — like a
GitHub Actions runner (RUNNER_NAME, RUNNER_KINDS=trash-label,trash-search, RUNNER_CONCURRENCY);GET /api/cron/tick every minute with Authorization: Bearer $CRON_SECRET (a Vercel
Cron on Pro, or any external pinger) so reminders and automations fire even when no runner is on.Admin → Background jobs shows runners and every job, with cancel and retry.
cp .env.example .env.local
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" # APP_ENCRYPTION_KEY
| Variable | Used for |
|---|---|
DATABASE_URL | Connection string; the database it names is created by db:setup. |
CRON_SECRET | Protects /api/cron/tick (and /api/cron/reminders). |
APP_ENCRYPTION_KEY | AES-256-GCM key for connector secrets. Rotating it makes stored secrets unreadable. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | OAuth client for the Gmail and Calendar connectors. Leave empty to keep them greyed out. |
GOOGLE_REDIRECT_URI | Optional. Leave unset — the redirect URI is derived per-request from whichever address you're connecting from (see below), which is what lets the same app be reached from localhost and, say, a phone over Tailscale without reconfiguring anything. Set this only to pin one fixed URL for a production deployment behind a stable domain. |
SESSION_SECRET | Signs the sign-in session cookie. Falls back to APP_ENCRYPTION_KEY if unset. Rotating it signs everyone out. |
ADMIN_EMAILS | Comma-separated Google account emails with access to /admin (see Admin). Empty means nobody. |
R2_ACCOUNT_ID / R2_ENDPOINT / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET | Cloudflare R2 (S3-compatible) storage for project photos/files and chat attachments (lib/storage.ts). Leave empty to store on local disk under UPLOADS_DIR instead. R2_ACCOUNT_ID only derives the default endpoint — set R2_ENDPOINT directly for a custom domain or jurisdiction-specific endpoint. |
npm run db:setup # creates the database if missing, applies db/schema.sql (idempotent)
There's no seed script — every table is real and org-scoped, and the app starts empty for each organization. The first person to sign in with Google creates their org and everything from there (customers, projects, invoices, vendors, budgets, …) is data they actually entered, not a fixture.
npm run dev
The app is multi-tenant and requires signing in with Google — there is no username/password.
Every table below the organizations one is scoped to an org; a signed-in person only ever
sees their own org's data. See lib/auth.ts.
/settings's Team section sends invites — an owner enters an email + role there; no email is
actually sent yet, so tell the person directly to sign in with that Google account).This is a second, separate Google OAuth client usage from the Connectors section below: login
only ever requests openid email profile (who you are) and never stores a token — once the
profile is read, a plain server-side session (sessions table + a signed cookie) takes over.
Connectors request Gmail/Calendar data access scopes and store long-lived tokens instead. Both
reuse the same GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET, but need their own entries under
Authorized redirect URIs on the OAuth client, since the callback paths differ:
{origin}/api/connectors/google/callback — connectors{origin}/api/auth/google/callback — sign-in...for every {origin} you actually use (http://localhost:3000, a Tailscale address, etc.) —
same requirement as the connectors flow, just a second path per address.
The core of the app: a customers table and a projects table (one property, one job of work, a
status from lead through completed/cancelled) that everything else hangs off, all scoped to
the signed-in org.
A project's type isn't hardcoded to one industry — it's a per-org, user-managed list
(project_types, lib/project-types.ts). A new org's owner picks a company type at /onboarding
(home improvement, retail, e-commerce, cleaning, landscaping, and more — see COMPANY_TYPES in
lib/data.ts), which seeds a starter set of project types for their org; /projects/types lets
them rename, add to, or remove from that list afterward.
/customers, lib/customers.ts) — contact info and every project for that customer./projects, lib/projects.ts) — the hub page (/projects/[id]) holds a project's
status, crew assignment, photos, estimates, schedule, tasks and files all in one place.lib/quoting.ts, lib/project-photos.ts) — upload photos of the project site, then
"Analyze photos with AI" sends them (as image_url parts, so the org's default LLM provider
needs vision support — every current OpenAI chat model and most local multimodal ones qualify)
to propose a line-itemized estimate. Nothing is ever saved or sent automatically: the proposal is
editable, saving it creates a draft row in estimates/estimate_line_items, and a separate
"Send" action emails it (sendMail in lib/gmail.ts, plain text, via a connected Gmail account)
only when someone clicks it./schedule, lib/schedule.ts) — schedule_entries ties a project to a crew
member and a time window; the standalone page lists the next 30 days and can filter to one
person (a simple stand-in for a per-person "route"). The same form appears on a project's own
hub page./tasks, lib/tasks.ts) — one table, three kinds (todo, shopping, permit),
optionally tied to a project; app/(dashboard)/tasks/{task-list,add-task-form}.tsx are shared
between the standalone page and a project's hub page.project_photos/project_files, bytes on local disk under UPLOADS_DIR, streamed back
through /api/projects/[projectId]/{photos,files}/[id], gated by the project's own org check)
alongside its estimates and an optional link from invoices.project_id for spend already tied
to that project.sendMail (built for quoting) is the same primitive a reply or a
schedule-confirmation email would use.A docked chat panel (components/executive-assistant-widget.tsx, opened from the bottom-right FAB
on every dashboard page) backed by the org's default LLM provider. Unlike a plain Q&A bot, it has
real tools (lib/assistant.ts) that read and write the org's actual data — creating customers,
projects, project types, and tasks, not just describing what it would do. Tool calling is done via
strict-JSON prompting (the same "reply with ONLY JSON" technique lib/quoting.ts uses for
estimates) rather than a provider's native function-calling API, since this app targets arbitrary
OpenAI-compatible endpoints whose function-calling support varies.
It knows what page the user is on (passed as pageContext on every request), can attach images
(sent to the vision model directly) or documents — markdown, PDF, Word, Excel, PowerPoint, each
real-parsed server-side (lib/document-extract.ts) into text rather than faked — and keeps
multiple switchable conversations per org (agent_conversations/agent_messages/agent_tool_calls),
reachable from the panel's history icon instead of a dedicated page.
db/schema.sql — every table, safe to re-run.lib/db.ts — the pg pool (one per process, reused across dev reloads).lib/queries.ts — every read the pages make. Pages call these, never pg directly.lib/data.ts — design and navigation config only: tones, category tiles, formatters.?status=, ?q=, ?page=), so pages
stay Server Components. The root layout is force-dynamic; nothing is prerendered./connectors manages the systems the dashboard talks to.
Any HTTP MCP server. Give it a name, URL and optionally a bearer token, API-key
header, or basic auth. Saving runs the MCP handshake right away (initialize, then
tools/list, over streamable HTTP, JSON or SSE replies) and records the server name,
its tools, and any failure against the row. Test re-runs it.
Gmail and Google Calendar. Gmail connects with gmail.modify (read plus trash and
labels — explicitly not permanent delete, which needs the much broader
https://mail.google.com/ scope this app doesn't request); Calendar is read-only
(calendar.readonly). A connector made before gmail.modify existed only has the old
gmail.readonly grant until it reconnects — hasGmailModifyScope gates the trash
actions on that, and the connectors list and cleanup screen both prompt to reconnect
when it's missing. Creating the OAuth client is not enough either way — enable the
Gmail API and Google Calendar API in the same project's API library, or the
first call comes back 403 accessNotConfigured even though consent succeeded.
Connect sends you to Google's consent screen; the callback at
/api/connectors/google/callback exchanges the code server-side, stores the tokens
encrypted, refreshes them when they expire, and proves the connection by calling the
Gmail profile or calendar-list endpoint.
The redirect URI sent to Google is built from the request that clicked Connect
(lib/request-origin.ts), not a fixed value — so it's correct whether you're on
localhost or opening the app over Tailscale from a phone, with no config change
between them. Google still requires an exact match against a pre-registered allowlist,
so every address you actually connect from must be added under Authorized redirect
URIs on the OAuth client in the
Google Cloud Console — /connectors
shows the exact URI the current request would use.
Secrets and tokens are encrypted with AES-256-GCM (lib/crypto.ts) before they reach
Postgres and are never sent to the browser — the connector list only knows whether a
secret exists.
/settings configures LLM providers — any OpenAI-compatible chat endpoint: OpenAI
itself, or a local server like LM Studio or Ollama. Give it a name and base URL (its API
root including the version path, e.g. http://<host>:1234/v1); saving lists the
endpoint's models via GET /models and picks one for you — there's no model field to
fill in by hand. Exactly one provider can be the default, and independently, exactly
one can be the email analyzer the cleanup worker calls — a smaller
local model can run analysis while a different one stays default, or the same one can be
both. Both assignments enforce "at most one" as a partial unique index in Postgres
(llm_providers_one_default, llm_providers_one_email_analyzer), so switching is one
atomic UPDATE, never a moment with none or two set.
/admin is a second, separate concern from org settings above — cross-tenant, not scoped
to any one organization, and visible only to whoever's Google account email is listed in
ADMIN_EMAILS (comma-separated; see lib/admin.ts). An org's own "owner" role grants
none of this, and admin status grants nothing inside an org (an admin doesn't automatically
see a specific org's customers, jobs, or invoices — only the platform-level views below).
/settings's org-level ones
(lib/platform-llm-providers.ts, its own platform_llm_providers table), but org-scoped
the other direction: whichever one is marked default here is the fallback every org's
defaultLlmProvider() call resolves to when that org hasn't configured (or enabled) its
own. An org's own default always wins when it has one — this only matters for an org that
hasn't brought its own key yet./email reads the connected Gmail account live — nothing is copied into Postgres.
Bills & receipts, Inbox, Unread, With attachments,
All mail), and the search box takes Gmail syntax (from:acme has:attachment).
Paging follows Gmail's opaque page tokens, kept as a trail in the URL so Prev works./api/email/[id]/attachments/[attachmentId], so the browser never sees a Google token.sandbox="" and
its own default-src 'none' CSP: no scripts, no remote images, no tracking pixels.
Inline data: images still show, and there is a plain-text toggle when the sender
supplied both parts.?account= so the right one's
token is used.labels.get per label in parallel — labels.list doesn't return
counts. Clicking a tile searches that label; Trash all spam appears specifically
when viewing Spam (see Inbox cleanup below for why it's trash, not permanent delete).Payload parsing (base64url bodies, nested multiparts, "Name" <addr> headers) lives in
lib/gmail-payload.ts as pure functions, separate from the API calls in lib/gmail.ts.
/email/cleanup finds old, low-value mail and lets you review it before anything is
touched — nothing is ever deleted automatically.
Analyze inbox queues a scan as a background job (see Background jobs above); a runner picks it up and the page shows its live progress. Job code runs inside whichever runner claims it, so editing it needs that runner restarted (the dev server's restarts with the server).
A scan can be scoped to the whole inbox or one label (Promotions, Social, Updates,
Forums) — lib/cleanup-heuristics.ts's pure rules narrow it down first (age floor,
promotional category, automated-looking sender, an "unsubscribe" mention — unread,
starred, and important mail is never a candidate, full stop), then the provider assigned
on /settings as the email analyzer reviews each candidate in a batch, writes a
plainer reason, and can veto a false positive outright. No analyzer configured just means
heuristics-only results — the scan still runs.
Every result is a row in cleanup_candidates, reviewed on the same page: check the ones
you agree with, then Dismiss (hides it) or Move to Trash (calls Gmail's
reversible messages.trash — 30-day recovery in Gmail, never a permanent delete).
Trash all spam on /email is the same trash call applied directly to everything
already in Spam, no review step, since spam is presumptively junk already.
Gmail's per-minute quota is real and scanning hundreds of messages can hit it —
lib/gmail.ts's call() retries a 429 (or a 403 that says the same thing) with
exponential backoff before giving up, and the scan's own concurrency is intentionally
lower than the interactive mailbox view for the same reason.