---
slug: graph-from-google-sheets
title: "Add, edit and query graph objects from Google Sheets"
kind: article
status: published
hash: 86a340dc60533802b1213d887f05288be715fff1183e8b6c94e2772d36845730
created: 2026-11-05T00:35:06.246Z
updated: 2026-11-05T00:43:50.762Z
summary: ""
tags: []
above: []
below: []
adjacent: []
nested: []
---
# Add, edit and query graph objects from Google Sheets

One object has one stable ID, stored once. Its page, JSON endpoint, embedded cards and query results resolve that ID. A person, house, parcel, source listing, social account and post are separate objects connected by named relationships.

## Execution contract

[Raw execution contract](/graph/contract.json) · [Google Sheets Apps Script](/graph/sheets.gs) · [Existing object schema](/schemas/jarticle.json)

Paste these six headers into A1:F1 of an empty Google Sheet tab. Each subsequent row is one REST request. E and F are results written by the script.

```text
method	path	body_json	if_match	http_status	response_json
```

In Extensions → Apps Script, paste the script from the link above, save, and reload the Sheet. Use Graph → Set my access key to enter your existing terminal key; it is stored in your user properties, not in cells. Google asks you to authorize the script on its first run. Select a request row, then Graph → Run selected request. There is no automatic on-edit action or recurring trigger. A row with an existing result must be reviewed and cleared before rerunning. Large responses are split into a response tab without losing their contents.

All requests use this origin: `https://miscsubjects.com`. These are the HTTP headers; the placeholders are not credentials:

```http
Content-Type: application/json
X-Terminal-Key: <your existing terminal key>
If-Match: <hash returned by GET, for an edit or delete>
```

Public JSON and query reads need no key. Writes and the owner API require your existing key. A Sheet is an interchangeable REST client; it does not become a second graph database.

## Smallest starting person

```json
{
  "slug": "new-person",
  "title": "New person",
  "kind": "person"
}
```

## Smallest starting house

```json
{
  "slug": "new-house",
  "title": "New house",
  "kind": "house"
}
```

`slug` and `title` are required by the existing schema; `kind` supplies the object type. These minimal records default to draft. The same pattern accepts a permit with `kind: "permit"`. IDs must be stable, lowercase, and use letters, digits and hyphens. Use a new ID only for a different object. Lookup the existing object before adding it; the API prevents duplicate IDs, but different IDs are not automatically deduplicated by name or address.

## Add a visible listing

Use `POST` in method, `/api/jarticles` in path, this JSON in body_json, and leave if_match empty. Change the example ID, title and facts to the actual object before executing.

```json
{
  "slug": "new-house",
  "title": "New house listing",
  "kind": "house",
  "status": "published",
  "facts": {
    "street": "Vichy Avenue"
  },
  "body": [
    {
      "type": "entity",
      "ref": "#"
    }
  ]
}
```

A person uses the same request with `kind: "person"`. The self-reference draws its existing object renderer. `201` creates the object. `409` means that ID already exists: read and edit it instead. A published object has `/a/new-house` and `/a/new-house.json`. Draft objects remain unavailable publicly.

## Read, edit and enrich

First run `GET /api/jarticles/new-house`. Copy the response hash into column D on the edit row. Then run `PATCH /api/jarticles/new-house` with:

```json
{
  "ops": [
    {
      "op": "set",
      "path": "facts.street",
      "value": "Vichy Avenue"
    },
    {
      "op": "set",
      "path": "facts.source_observation",
      "value": {
        "source_url": "<actual source URL>",
        "observed_at": "<actual observation timestamp>",
        "source_date": null
      }
    }
  ]
}
```

Enrichment is an edit to the existing object or an added source object with a relationship to it. Keep original source dates, current observation dates and derived values distinct. Do not make up missing dates. A stale hash returns `412`: read the latest record and reconcile the edit. A PATCH only changes the named paths; setting an array replaces that array, so preserve existing entries when adding a relationship.

For a current residence, edit the person’s `facts.relations` to include the relationship below along with its existing relationships. Replace the evidence with the actual source. A prior address uses `previously_resided_at` and `temporal_scope: "historical"`; a workplace never implies residence.

```json
{
  "ref": "new-house",
  "predicate": "resides_at",
  "temporal_scope": "current",
  "as_of": "2026-11-04",
  "valid_from": null,
  "valid_to": null,
  "evidence": {
    "type": "owner_statement",
    "statement": "Use the actual residence statement here.",
    "source_date": "2026-11-04",
    "observed_at": "<actual observation timestamp>"
  }
}
```

For source enrichment, store the source account name, exact query, discovered URL, observation time and edge meaning in the evidence. A handle match means a handle match, not verified shared ownership. Direct owner statements and independently observed source facts remain separately labeled.

## One house, multiple views

[Cyrus Massoumi](/a/cyrus-massoumi) and [337 Melrose Avenue](/a/337-melrose-avenue) demonstrate this. The person stores a `previously_resided_at` relationship to the house. The house reads the incoming relationship as its prior resident; it does not store a copied person or copied house. Zillow and Redfin are their own source-listing objects linked to that house; the parcel remains distinct.

To display that same house anywhere, add this block to the existing page’s body:

```json
{
  "type": "entity",
  "ref": "337-melrose-avenue"
}
```

The renderer resolves its current data. Editing the house updates subsequent renders wherever it is referenced. Each card’s Object JSON control reads the same canonical JSON. The JSON response may include a generated relationship view; that view is not an independently stored duplicate.

## Query from any Sheet

Use `POST /api/jarticles/query`, with a query object in body_json. A public browser can also read `/graph/query.json?q=<URL-encoded query JSON>`. Results include object IDs, page and JSON URLs, matching relationship evidence, total count, provider coverage and a `next` query. Use `next` as the following request body until it is null. `limit` is 1–200.

People whose graph records say they currently live on Vichy Avenue:

```json
{
  "kind": "person",
  "related": {
    "predicate": "resides_at",
    "to": {
      "kind": "house",
      "where": {
        "facts.street": "Vichy Avenue"
      }
    },
    "current": true
  },
  "scope": "all"
}
```

[View current Vichy Avenue residents](/graph/query/people-on-vichy-avenue). This tests `resides_at`, not former residence, a text mention or a workplace address. A zero result means no matching stored published assertion.

All Gmail-domain email objects:

```json
{
  "kind": "email",
  "where": {
    "facts.domain": "gmail.com"
  },
  "scope": "all"
}
```

[View Gmail addresses](/graph/query/gmail-addresses). `massoumi.cyrus@gmail.com` matches. `cc@llmasos.com` and `cyrus@dsco.co` are separate email objects; a custom domain is not gmail.com even if Google hosts its mailbox.

All LinkedIn profile objects:

```json
{
  "kind": "social_profile",
  "where": {
    "facts.platform": "linkedin"
  },
  "scope": "all"
}
```

[View LinkedIn profiles](/graph/query/linkedin-profiles). This includes person and organization accounts. The County of Marin LinkedIn account belongs to the government organization; the geographical county remains a distinct object.

`scope: "all"` reads both canonical article objects and the existing Atlas inventory. It omits private or excluded records and does not count raw discovery observations as merged people. Articles precede Atlas in combined pagination; sorting applies within articles, while Atlas uses stable ID order. Social feeds below use canonical post objects and source publication time.

## Categories and connections

[People](/a/person-objects) · [Houses](/a/property-objects) · [Facebook profiles](/graph/query/facebook-profiles) · [Instagram profiles](/graph/query/instagram-profiles) · [LinkedIn profiles](/graph/query/linkedin-profiles) · [Email addresses](/graph/query/email-addresses) · [X profiles](/a/x-profiles). Categories resolve their current matching records, rather than retaining copies.

To list direct subsidiaries or contained objects, query `{"related":{"predicate":"part_of","to":"<parent-id>"}}`. Query `owned_by` for ownership, `works_at` for employment, or `account_of` for profiles. These predicates have different meanings. A category is not automatically an ownership relationship.

[William Kasel](/a/william-kasel) has separate Instagram and Facebook profiles. Your Instagram follows his and his follows yours. The Facebook friend relationship is separately recorded. The cross-platform identity link is owner asserted, supported by the observed name William Kasel; a shared name alone does not prove identity. His observed Instagram bio mentions Loop.health. Your statement that you both currently work at [DSCO](/a/dsco) is a separate, dated employment assertion. [People at DSCO](/a/dsco-people) resolves the two people through those links.

## Query the links connecting Cyrus and Will

This query requires all four paths: following each other on Instagram, Facebook friendship, and both people having a current work relationship to DSCO. The result carries the matched edges and their evidence; it does not claim that equal names independently verify identity.

```json
{
  "kind": "person",
  "related": [
    {
      "predicate": "has_account",
      "to": {
        "kind": "social_profile",
        "where": {
          "facts.platform": "instagram"
        },
        "related": [
          {
            "predicate": "follows",
            "to": "cyrus-massoumi-instagram"
          },
          {
            "predicate": "follows",
            "from": "cyrus-massoumi-instagram"
          }
        ]
      }
    },
    {
      "predicate": "has_account",
      "to": {
        "kind": "social_profile",
        "where": {
          "facts.platform": "facebook"
        },
        "related": {
          "predicate": "friend_of",
          "to": "cyrus-massoumi-facebook"
        }
      }
    },
    {
      "predicate": "works_at",
      "current": true,
      "to": {
        "id": "dsco",
        "related": {
          "predicate": "works_at",
          "from": "cyrus-massoumi",
          "current": true
        }
      }
    }
  ]
}
```

[View the shared connections](/graph/query?q=%7B%22kind%22%3A%22person%22%2C%22related%22%3A%5B%7B%22predicate%22%3A%22has_account%22%2C%22to%22%3A%7B%22kind%22%3A%22social_profile%22%2C%22where%22%3A%7B%22facts.platform%22%3A%22instagram%22%7D%2C%22related%22%3A%5B%7B%22predicate%22%3A%22follows%22%2C%22to%22%3A%22cyrus-massoumi-instagram%22%7D%2C%7B%22predicate%22%3A%22follows%22%2C%22from%22%3A%22cyrus-massoumi-instagram%22%7D%5D%7D%7D%2C%7B%22predicate%22%3A%22has_account%22%2C%22to%22%3A%7B%22kind%22%3A%22social_profile%22%2C%22where%22%3A%7B%22facts.platform%22%3A%22facebook%22%7D%2C%22related%22%3A%7B%22predicate%22%3A%22friend_of%22%2C%22to%22%3A%22cyrus-massoumi-facebook%22%7D%7D%7D%2C%7B%22predicate%22%3A%22works_at%22%2C%22current%22%3Atrue%2C%22to%22%3A%7B%22id%22%3A%22dsco%22%2C%22related%22%3A%7B%22predicate%22%3A%22works_at%22%2C%22from%22%3A%22cyrus-massoumi%22%2C%22current%22%3Atrue%7D%7D%7D%5D%7D)

## Posts, tags, place and recency

Each post is stored once and references its author account with `posted_by`. That account references its person or organization with `account_of`. A person’s `associated_with_place` relationship can connect an otherwise untagged post to Marin. This does not rewrite the post as mentioning Marin.

A tagged Marin IJ account uses `tags_account`; the account links to the Marin Independent Journal organization. The literal hashtag uses `has_hashtag`. These are independent paths. Add future posts through the same create request, giving each its source ID/URL, text, author-account reference, source publication time, timestamp precision, observation time and relationships. The existing queries include them on the next read; they do not themselves crawl social networks.

[Last nine posts by Marin people](/a/marin-social-posts) · [Last nine including their organizations](/a/marin-people-and-organizations-posts) · [Posts tagging Marin IJ](/a/marinij-tagged-posts) · [Posts containing #marincounty](/a/marincounty-hashtag-posts) · [Facebook posts containing #marincounty](/a/facebook-marincounty-posts). The current example has six personal-account posts and nine when MiscSubjects’ X account is included.

Posts containing #marincounty across networks:

```json
{
  "kind": "social_post",
  "related": {
    "predicate": "has_hashtag",
    "to": "hashtag-marincounty"
  },
  "sort": "facts.published_at",
  "direction": "desc"
}
```

Posts tagging Marin IJ across networks:

```json
{
  "kind": "social_post",
  "related": {
    "predicate": "tags_account",
    "to": {
      "kind": "social_profile",
      "related": {
        "predicate": "account_of",
        "to": "marin-independent-journal"
      }
    }
  },
  "sort": "facts.published_at",
  "direction": "desc"
}
```

Add `"where":{"facts.platform":"facebook"}` (or `linkedin` / `x`) to filter a network. Results also include network counts. Use `"sort":"facts.published_at","direction":"desc","limit":9` for latest nine. Facebook times are observed to the minute; LinkedIn timestamps are derived from activity IDs and labeled as such; X timestamps come from its time element. Tied or imprecise timestamps do not establish exact ordering.

## REST export and continuity

For a current query feed, repeat the same request whenever you want a refresh. Use `updated_since` for objects edited since an ISO timestamp; this means graph edit time, not original post time. Follow every `next` query to finish a feed.

For a complete mirror of public canonical article objects, read [the manifest](/graph/manifest.json?limit=200), follow every `next` URL verbatim, and compare each ID and hash with your destination. Fetch changed JSON URLs. After one complete snapshot, remove IDs absent from that manifest from your mirror. If the manifest changes between pages, the server returns `409`; restart the snapshot. This detects deletions and unpublishing that an edit-time query alone would miss. Atlas can be exported by paginating the appropriate scoped queries; the article manifest explicitly excludes Atlas.

Write the returned JSON to your chosen system using that system’s API. Any Sheets script or external client can perform these requests. The supplied script runs selected rows only; it installs no background sync and sends nothing to social networks.

Apps Script uses Google’s [UrlFetchApp](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app) and [PropertiesService](https://developers.google.com/apps-script/reference/properties/properties-service).
