# Check images (/docs/build/images)



Many quests ask members for a picture. Domino can check it for you, so your team only looks at the unclear ones. For example:

* **"Use our campaign picture as your X avatar."** Domino compares the member's X profile picture with yours.
* **"Upload a selfie at our booth."** Domino spots the same photo submitted by several people, then AI checks it against your criteria.
* **"Show that you joined our X Community."** AI reads the screenshot and Domino compares what it shows with the member's linked X account.

The member gets an answer: accepted, not accepted with a reason, or sent to your team for review when the quest uses review. Images are never served publicly, and the AI reviewer receives copies without location data.

> [!DEV]
> Quest evaluators check images through `ctx.images`. The platform fetches and decodes each image and returns only the result. Quest code never receives image bytes or URLs, and it cannot make the platform fetch an arbitrary address.

To start from a working quest, install a recipe:

| Recipe                 | Name in the URL        | Checks                                                                           |
| ---------------------- | ---------------------- | -------------------------------------------------------------------------------- |
| X profile picture      | `x-profile-picture`    | The linked X avatar is your campaign picture, or a customized version AI accepts |
| Unique photo           | `unique-photo`         | An uploaded photo was not already submitted by someone else, then AI review      |
| X Community screenshot | `community-screenshot` | A screenshot shows your Community, membership and the linked X username          |

```sh
pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/x-profile-picture.json
```

Each recipe installs `quests/<name>.quest.ts` and instructions under `docs/snippets/`. See [start from a snippet](first-quest.md#start-from-a-snippet).

| To                                                   | Use                                                                                                |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Require your campaign picture as an X avatar         | [`compare`](#compare-two-images) against a [reference image](#1-add-reference-images-to-relayjson) |
| Accept a customized version, such as a framed selfie | [`review`](#review-images-with-ai) with the reference as a second image                            |
| Read a screenshot, such as a joined X Community      | [`extract`](#read-fields-from-a-screenshot)                                                        |
| Catch the same photo submitted by several people     | [`seen`](#catch-reused-photos)                                                                     |
| Get dimensions, format and hashes                    | [`inspect`](#inspect-an-image)                                                                     |

## Name an image [#name-an-image]

Every method takes image references, not files or URLs:

| Reference                       | Image                                                                                                                          |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `{ evidence: ctx.input.photo }` | The photo the member uploaded with this attempt. Another attempt's upload is refused.                                          |
| `{ x: "profile" }`              | The linked X account's current profile picture, at 400×400. The member must link X first.                                      |
| `{ x: "post", postId, index }`  | The image attached to an X post, where `index` is its position: `0` (default) to `3`. Videos and GIFs use their preview image. |
| `{ asset: "key" }`              | A reference image you deployed with the project.                                                                               |

X images are downloaded only from X's image hosts. X post reads also list attached media in `post.media`, as `{ type, url }` in post order.

## Add reference images [#add-reference-images]

### 1. Add reference images to relay.json [#1-add-reference-images-to-relayjson]

List each image with a key and a path relative to `relay.json`:

```json
{
  "images": [
    { "key": "campaign-avatar", "path": "assets/campaign-avatar.png" }
  ]
}
```

Use JPEG, PNG or WebP files of at most 5 MiB and 25 megapixels, up to 10 per deployment. Commit them with your project.

### 2. Deploy [#2-deploy]

Run `domino deploy`. The CLI uploads each new file straight to private storage, then previews the deployment with only each file's hash and size; unchanged files are not uploaded again. The platform decodes every image before publication and rejects anything that is not a supported image. The deployment summary lists the published images. Images are never served publicly.

Hosted builds publish the same images from your repository: build them with `domino build --hosting-artifacts`, as the starters do, or keep `domino build --json` output unchanged in your build script.

Deploying a key again replaces its image for later checks. Removing an entry from `relay.json` does not delete the published image. Results that were already captured for an attempt stay unchanged.

## Compare two images [#compare-two-images]

`compare` returns perceptual hash distances between 0 (same picture) and 64:

```ts
import { x, defineQuest, settings, manual, once, decision } from "@domino-sdk/relay/authoring";

export default defineQuest({
  integrations: [x],
  key: "campaign-avatar",
  title: "Use the campaign picture on X",
  settings: settings.object({}),
  trigger: manual(),
  completion: once(),
  rewards: [],
  async evaluate(ctx) {
    if (!(await ctx.x.connection())) {
      return decision.reject({ reason: "Connect X, then start a new submission." });
    }

    const match = await ctx.images.compare({ x: "profile" }, { asset: "campaign-avatar" });

    return match.phashDistance <= 8
      ? decision.accept()
      : decision.reject({ reason: "Set the campaign picture as your X avatar." });
  },
});
```

| Result          | Meaning                                                                                                                                      |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `identical`     | The two files have the same bytes.                                                                                                           |
| `phashDistance` | Differences in overall structure. Up to about 8 means the same picture resized or re-encoded. Unrelated photos usually differ by 25 or more. |
| `dhashDistance` | Differences in local brightness changes. It reacts more to small edits.                                                                      |

Your quest decides the threshold. Make it a setting so operators can tune it in Console without a code change:

```ts
import { x, defineQuest, settings, manual, once, decision } from "@domino-sdk/relay/authoring";

export default defineQuest({
  integrations: [x],
  key: "campaign-avatar-tunable",
  title: "Use the campaign picture on X",
  settings: settings.object({
    maxDistance: settings.integer({
      label: "Largest hash distance that counts as the same picture (0–64)",
      default: 8,
      min: 0,
      max: 64,
    }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [],
  async evaluate(ctx) {
    const match = await ctx.images.compare({ x: "profile" }, { asset: "campaign-avatar" });

    return match.phashDistance <= ctx.settings.maxDistance
      ? decision.accept()
      : decision.reject({ reason: "Set the campaign picture as your X avatar." });
  },
});
```

Hashes tolerate resizing, compression and small color changes. Crops, rotations, overlays and added frames change them substantially. Use [AI review](#review-images-with-ai) when a member may customize the picture.

## Review images with AI [#review-images-with-ai]

`review` judges up to four images against your criteria and returns `pass`, `fail` or `unclear` with a short reason. Images are numbered in the order you pass them, so the criteria can refer to them:

```ts
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring";

// Call from evaluate() after checking that X is connected.
export async function framedAvatar(ctx: EvaluationContext<unknown, unknown>) {
  const result = await ctx.images.review({
    images: [{ x: "profile" }, { asset: "campaign-avatar" }],
    criteria: "Image 1 uses the frame and logo from image 2. The person's own photo may appear inside the frame.",
  });

  if (result.kind === "unclear") {
    return decision.review({ reason: result.reason });
  }

  return result.kind === "pass" ? decision.accept() : decision.reject({ reason: result.reason });
}
```

The AI reviewer receives re-encoded copies without EXIF or location data, never the image URL. It is told to ignore instructions inside the images, but AI can still misjudge adversarial pictures, so send unclear results to your team.

> [!DEV]
> Return `decision.review()` only when the quest enables human review. With review off, it becomes a rejection that keeps your reason and marks the attempt `requestedReview`, which Console shows as "Would have gone to review; human review is off". To review an uploaded photo, pass `{ evidence: ctx.input.photo }`. `ctx.review.photo()` is deprecated: it still works, reviews only the uploaded photo and will be removed in 2.0.0.

## Read fields from a screenshot [#read-fields-from-a-screenshot]

`extract` reads up to 12 declared fields from one image. Each field is a `string`, `number` or `boolean`, and is `null` when the image does not show it clearly:

```ts
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring";

// For a quest with trigger: manual({ input: evidence.photo() }).
export async function joinedCommunity(ctx: EvaluationContext<unknown, { photo: string }>) {
  const screenshot = await ctx.images.extract({
    image: { evidence: ctx.input.photo },
    fields: {
      community: { type: "string", description: "The X Community name shown on the page" },
      joined: { type: "boolean", description: "Whether the page shows the viewer as a member" },
    },
  });
  const { community, joined } = screenshot.values;

  if (community === null || joined === null) {
    return decision.review({ reason: screenshot.reason });
  }

  return community === "Domino Builders" && joined
    ? decision.accept()
    : decision.reject({ reason: "Upload a screenshot after joining the community." });
}
```

A screenshot is a member's claim, not proof from X. Compare extracted values with data you can verify, such as the linked account's username, and send uncertain cases to staff.

## Catch reused photos [#catch-reused-photos]

A photo booth or a "share your setup" quest invites copying. `seen` records the image for this attempt and counts other members whose earlier images are within the distance:

```ts
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring";

// Call first in evaluate(); continue with your own checks when it returns null.
export async function reusedPhoto(ctx: EvaluationContext<unknown, { photo: string }>) {
  const seen = await ctx.images.seen({ evidence: ctx.input.photo });

  return seen.matches > 0
    ? decision.review({ reason: "This photo matches another member's submission." })
    : null;
}
```

The result contains `matches`, the `closestDistance`, and `firstSeenAt` in milliseconds. The default `maxDistance` is 8; pass `{ maxDistance }` from 0 to 8 to change it, for example from a quest setting as the `unique-photo` recipe does. The same member's other attempts never count. Only images passed to `seen` are recorded, within the same project and environment. `seen` records the image when it runs, even if the attempt is later rejected, so two people submitting the same photo at once see each other.

## Inspect an image [#inspect-an-image]

`inspect` returns `width` and `height` after EXIF rotation, `format`, `bytes`, `sha256`, and the 16-character `phash` and `dhash`. Use it to require a minimum size or to store hashes in your own records.

> [!DEV]
> Image result readers ignore added fields, including nested upload metadata. For explicit parsing of a newer server variant, `capabilityResponses` from `@domino-sdk/relay/schema` preserves an unknown image format as `{ kind: "unsupported", raw }`. Existing evaluator methods retain their declared types and their evaluator protocol; new formats must stay compatible with that protocol.

## Limits and failures [#limits-and-failures]

| Limit                        | Value                                                     |
| ---------------------------- | --------------------------------------------------------- |
| Images per `review`          | 4                                                         |
| Fields per `extract`         | 12                                                        |
| Criteria                     | 2,000 characters                                          |
| Downloaded or uploaded image | 5 MiB and 25 megapixels                                   |
| Photo uploads by a member    | 30 an hour; past it, uploads answer 429 `rate-limited`    |
| Reference images             | JPEG, PNG or WebP; 5 MiB each; 10 per deployment          |
| Capability calls             | Each method call uses one of the evaluation's eight calls |

Member avatar uploads are decoded by the same image service with the same 5 MiB and 25 megapixel limits, then stored as 512×512 WebP. They are profile pictures, not quest evidence; see [member profiles](/docs/build/profiles#upload-a-picture).

A technical failure, such as an image service outage, never passes or rejects a member; when the quest code lets the error through, Domino retries it. To check a changed picture or account, the member starts a new submission.

> [!DEV]
> Every result is captured for the attempt and reused on evaluator retries. Failures throw a `CapabilityError` instead of passing or rejecting: `not-linked` for `{ x: "profile" }` without a linked X account; `author-error` for a missing reference image, an image that cannot be decoded and a missing X picture; `provider-unavailable`, which Domino retries if your code lets it through, for provider failures and timeouts. The attempt pins the member's X account at its first X read. See [evaluator readers](/docs/reference/verification#evaluator-readers-null-errors-and-pinning).

> [!NOTE] Checks happen at verification time
> A matching profile picture proves the picture at that moment. It does not prove that the member kept it.

The recipes above are complete quests built from these methods. Each one is also an SDK example in `packages/sdk/examples`.

## Retire an image [#retire-an-image]

Archive an image in **Settings → Resources**, or run `domino images archive <key> --reason <text>` in test. Fresh evaluator reads of that image name fail while it is archived. Stored bytes and image evidence already captured by an attempt remain available for replay. Publishing the image again preserves archive state. Unarchive it explicitly before new checks should capture it again.
